Skip to content

[Windows] Application::Quit posts WM_QUIT before destroying windows #57

Description

@lijy91

On Windows, Application::Quit() ends in PostQuitMessage() (src/platform/windows/application_windows.cpp) without destroying the application's windows first.

window_manager had the same pattern in destroy(), and there it made apps take several seconds to exit, or stop responding, since Flutter 3.24. The window and the Flutter engine were torn down after the message loop had already stopped.

Expected: destroy the windows (so the Flutter view controller shuts down its engine while messages are still pumped), then post WM_QUIT. Needs checking in a Flutter app, including with a close handler that intercepts the system close button.

Background:

Activity

  1. lijy91 commented on Sep 27, 2026

    @lijy91
    MemberAuthor

    Investigated on Windows 11 with Flutter 3.47.5 (stable), using flutter_window_example (the FlutterViewController runner) and flutter_multiple_window_example (the headless-engine runner), with Application.instance.quit(7) fired from a timer.

    What happens today: a crash during teardown, not a hang

    The multi-second hang from window_manager#478 does not reproduce on 3.47.5: the process is gone about 100 ms after quit(). But it does not end cleanly. Under cdb, once the loop has ended:

    window_example!wWinMain
     → FlutterWindow::~FlutterWindow            // member flutter_controller_ is destroyed
     → FlutterDesktopViewControllerDestroy
     → NtUserDestroyWindow                      // the engine destroys the Flutter view (child HWND)
     → WM_PARENTNOTIFY to the still-alive top-level window
     → (core's window subclass procs, forwarding)
     → Win32Window::WndProc → FlutterWindow::MessageHandler
     → FlutterViewController::HandleTopLevelWindowProc   // on the half-destroyed controller
     → flutter_windows.dll: access violation reading [rcx+10h], rcx = 0
    

    Quit() stops the loop with the top-level window still alive. The runner then destroys the controller from ~FlutterWindow rather than from OnDestroy, and MSVC's unique_ptr does not clear the pointer before it deletes. So the parent's message handler calls into the controller while it is being destroyed. The engine's normal shutdown is skipped: an end-of-wWinMain trace never prints, and Dart gets no lifecycle callbacks. Without a debugger the process still exits with code 0 and leaves no WER event. My guess is that x64 Windows swallows the exception because it is raised inside a window-proc callback; I did not pin down what finally ends the process. The multi-window example shows the same missing end-of-wWinMain trace, but I did not run it under the debugger.

    Fix (core cd7cd6d, not pushed yet)

    When the host owns the loop, Quit() now emits ApplicationExitingEvent and then posts a step to the main thread that:

    1. destroys the thread's top-level windows. It uses DestroyWindow, not WM_CLOSE, so a close handler that intercepts the close button cannot veto the quit. Message-only windows (dispatcher, menu and shortcut hosts) are not enumerated.
    2. posts WM_QUIT(exit_code) last, after the PostQuitMessage(0) the runner issues on WM_DESTROY. The last one wins, so the exit code survives.

    The step is posted rather than run inline. With merged platform and UI threads, the call comes from inside a Dart FFI call, and shutting the engine down under its own running isolate is unsafe; I did not test the inline variant. Run()'s own loop is unchanged.

    Results

    Time from quit() until the process exited, 3 runs each:

    before after
    flutter_window_example 93–107 ms (via the crash above) 192–233 ms, clean
    flutter_multiple_window_example 63–69 ms 177–179 ms, clean

    The extra time is the engine shutting down properly. After the fix, in the single-window example: the controller reset in OnDestroy takes ~63 ms, wWinMain returns at ~78 ms, and CRT exit plus DLL, Dart VM and driver-thread teardown take the rest. There is no access violation, and the process ends through the CRT's exit. The multi-window example now also delivers AppLifecycleState.inactive and hidden to Dart.

    A C++ probe linked against core pumps the loop like a host. It creates a core window plus a runner-like window that vetoes WM_CLOSE and posts WM_QUIT(0) on destroy:

    • Before: the windows survive the quit.
    • After: all checks pass:
      • both windows are alive during ApplicationExitingEvent, and destroyed afterwards (the vetoing one exactly once);
      • the host sees WM_QUIT with Quit()'s code (7/9/11), not 0;
      • a second quit in the same process and a quit from a worker thread both work;
      • the dispatcher still works afterwards.

    The exit code cannot be checked end to end in the Flutter examples, because the stock runner's wWinMain always returns EXIT_SUCCESS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions