Skip to content

[desktop_multi_window]Linux: closing a child window can crash the app #488

Description

@RelicOfTesla

Linux: closing a desktop_multi_window child window can crash the app

On Linux/X11, all three tested child-window closing paths trigger an
application crash:

  • windowManager.close() requested by the host window
  • windowManager.destroy() requested by the host window
  • Clicking the native title-bar X button

The three paths trigger the same class of failure during child-window cleanup.

Reproduce Steps

flutter create --platforms=linux desktop_multi_window_crash
cd desktop_multi_window_crash
  1. Replace pubspec.yaml with the content below.
  2. Replace lib/main.dart with the content below.
  3. Apply the Linux runner patch below.
  4. Run the application:
flutter pub get
flutter build linux --debug
GDK_SYNCHRONIZE=1 \
  build/linux/x64/debug/bundle/desktop_multi_window_crash

The host window provides three buttons. Each button creates one child window
with a different argument. Run one test at a time and close the previous child
window before starting another test.

  • Automatic close: the host requests windowManager.close() after three
    seconds, then the process crashes during cleanup.
  • Automatic destroy: the host requests windowManager.destroy() after
    three seconds, then the process crashes during cleanup.
  • Manual X close: no automatic operation is performed. Click the native
    X; the process then crashes during cleanup.

pubspec.yaml

name: desktop_multi_window_crash
description: Reproduction for a Linux child-window close crash.
publish_to: none
version: 1.0.0+1

environment:
  sdk: ^3.12.2

dependencies:
  flutter:
    sdk: flutter
  desktop_multi_window: 0.3.0
  window_manager: 0.5.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

flutter:
  uses-material-design: true

lib/main.dart

The argument selects the child-window behavior. The child content updates
every 16 ms so that the Flutter view remains active while the native window is
being closed.

import 'dart:async';

import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';

const manualChildArgument = 'child-manual-close';
const automaticCloseChildArgument = 'child-auto-close';
const automaticDestroyChildArgument = 'child-auto-destroy';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final controller = await WindowController.fromCurrentEngine();

  final argument = controller.arguments;
  if (argument == manualChildArgument ||
      argument == automaticCloseChildArgument ||
      argument == automaticDestroyChildArgument) {
    await windowManager.ensureInitialized();
    final currentWindow = await WindowController.fromCurrentEngine();
    await currentWindow.setWindowMethodHandler((call) async {
      switch (call.method) {
        case 'window_close':
          await windowManager.close();
          return null;
        case 'window_destroy':
          await windowManager.destroy();
          return null;
        default:
          throw MissingPluginException('Not implemented: ${call.method}');
      }
    });
    runApp(ChildApp(mode: argument));
    return;
  }

  runApp(const HostApp());
}

class HostApp extends StatelessWidget {
  const HostApp({super.key});

  Future<void> _openChild(String argument) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    final controller = await WindowController.create(
      WindowConfiguration(arguments: argument, hiddenAtLaunch: false),
    );
    await controller.show();
    if (argument == automaticCloseChildArgument) {
      await Future<void>.delayed(const Duration(seconds: 3));
      await controller.invokeMethod('window_close');
    } else if (argument == automaticDestroyChildArgument) {
      await Future<void>.delayed(const Duration(seconds: 3));
      await controller.invokeMethod('window_destroy');
    }
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
        home: Scaffold(
          body: Center(
            child: Wrap(
              spacing: 12,
              children: [
                ElevatedButton(
                  onPressed: () => unawaited(_openChild(automaticCloseChildArgument)),
                  child: const Text('Automatic close'),
                ),
                ElevatedButton(
                  onPressed: () => unawaited(_openChild(automaticDestroyChildArgument)),
                  child: const Text('Automatic destroy'),
                ),
                ElevatedButton(
                  onPressed: () => unawaited(_openChild(manualChildArgument)),
                  child: const Text('Manual X close'),
                ),
              ],
            ),
          ),
        ),
      );
}

class ChildApp extends StatelessWidget {
  const ChildApp({required this.mode, super.key});

  final String mode;

  @override
  Widget build(BuildContext context) => MaterialApp(
        home: ChildSession(mode: widget.mode),
      );
}

class ChildSession extends StatefulWidget {
  const ChildSession({required this.mode, super.key});

  final String mode;

  @override
  State<ChildSession> createState() => _ChildSessionState();
}

class _ChildSessionState extends State<ChildSession> {
  Timer? timer;
  int frame = 0;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
      if (mounted) setState(() => frame++);
    });
  }

  @override
  void dispose() {
    timer?.cancel();
    debugPrint('Child session disposed');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ColoredBox(
          color: Colors.primaries[frame % Colors.primaries.length],
          child: SizedBox(
            width: 240,
            height: 120,
            child: Center(
              child: Text(switch (mode) {
                automaticCloseChildArgument => 'Automatic close test',
                automaticDestroyChildArgument => 'Automatic destroy test',
                _ => 'Manual X close test',
              }),
            ),
          ),
        ),
      ),
    );
  }
}

Linux runner patch

Register all generated plugins for each child Flutter engine:

diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc
@@
 #include "flutter/generated_plugin_registrant.h"
+#include "desktop_multi_window/desktop_multi_window_plugin.h"
@@
   fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+  desktop_multi_window_plugin_set_window_created_callback(
+      [](FlPluginRegistry* registry) {
+        // Register plugins for the child Flutter engine.
+        fl_register_plugins(registry);
+      });

This follows the plugin registration pattern documented by
desktop_multi_window.

Observed result

All three paths can produce the following error before the process crashes:

The implicit view cannot be removed.

Depending on GTK, GLX, and window-manager timing, the process may also report:

BadAccess (attempt to access private resource denied)

or invalid FlView/GtkWidget pointer warnings, followed by a segmentation
fault.

Expected behavior

Closing any child window should release that child window's resources and
leave the host application running. No implicit-view error, GTK/GLX error, or
segmentation fault should occur.

Version (please complete the following information):

  • Flutter Version: 3.44.6 stable
  • Dart Version: 3.12.2
  • OS: Ubuntu/Linux with X11
  • Plugin: desktop_multi_window: 0.3.0
  • Related plugin: window_manager: 0.5.2

Additional context

desktop_multi_window creates a child window with its own Flutter engine and
an implicit Flutter view. During child-window close, Flutter tries to remove
that implicit view, but the engine rejects the operation with
kInvalidArguments. Native GTK/GLX cleanup then continues with an invalid or
already-invalid view state. The final error depends on timing, so it may be a
GLX error or a segmentation fault.

The three paths have the same observed failure class. This reproduction does
not show a reliable safety difference between close(), destroy(), and the
native X button.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions