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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions