Repository navigation
Description
Steps to reproduce
On macOS, a Flutter app permanently freezes when the laptop lid is closed and an external display is disconnected, as long as the Flutter app is backgrounded at the moment of disconnect and is running a non-trivial scene. The freeze does not recover even when the external display is reconnected.
Minimum repro:
- Build a Flutter macOS app running a non-trivial scene — many widgets, complex paths, or any workload that keeps the raster thread busy and the Metal pipeline carrying in-flight command buffers. A blank
MaterialAppor "hello world" scaffold does not reproduce; the trigger needs real GPU work in flight at the moment of display loss. - Move the window onto an external display (in my repro: 2560×1440 @ scale 2.0, mirrored via USB-C).
- Give focus to a different app (the Flutter app must not be the active app —
NSApp.isActive == false). - Close the laptop lid (MacBook Air M2 in my case).
- Disconnect the external display.
The Flutter app is now permanently frozen. Reconnecting the display fires NSApplicationDidChangeScreenParameters and NSWindow.didChangeScreen notifications normally, but no new frames are ever produced — the app remains unresponsive until the user kills and relaunches it.
If the app is foregrounded during the disconnect (key window), the freeze does not occur. The combination of backgrounded + lid closed + external unplug + non-trivial scene under render load is required.
Expected results
The raster thread recovers when the display topology stabilizes (either when the lid reopens, the external is reconnected, or the app is brought to the foreground).
Actual results
The raster thread is permanently wedged. The Dart isolate and AppKit main thread remain alive, but no frames are submitted. The app is visually frozen until process exit.
This is NOT renderer-specific (Skia A/B verified)
I initially assumed this was an Impeller-specific bug. It is not. I rebuilt the same app with FLTEnableImpeller=false (Skia) and reproduced the identical freeze under the same conditions. Same fingerprint: AppKit main thread alive, Dart isolate alive, no frames produced, window_manager package's Resize timed out firing repeatedly.
Both Flutter renderers on macOS ultimately present via CAMetalLayer (OpenGL is deprecated on Apple), so they share the same vulnerability to the underlying Apple-side [CAMetalLayer nextDrawable] deadlock that's been documented across multiple non-Flutter Metal apps (see references below). The Flutter macOS embedder appears to lack the defenses needed to handle the "host display vanishes while backgrounded with drawables in flight" scenario, regardless of which renderer is selected.
Diagnostic evidence (which thread is stuck)
I instrumented the app with three independent liveness signals and captured a freeze:
- Swift
NSTimer1 Hz heartbeat on the AppKit main thread —RunLoop.main.add(timer, forMode: .common)so it fires through modal/menu tracking. Continued firing throughout the freeze with the expected ~1000 ms cadence. - Dart
Future.delayed(500 ms)callback fired from a method-channel handler — used as an isolate-liveness signal. Fired on schedule throughout the freeze. - AppKit display-change notifications (
NSApplicationDidChangeScreenParameters,NSWindow.didChangeScreen,NSWindow.didChangeBackingProperties) — all dispatched and processed normally, withDispatchQueue.main.asyncfollow-up callbacks landing within milliseconds.
The Dart side hooks SchedulerBinding.addTimingsCallback and observes that no FrameTiming reports arrive after the freeze begins — confirming the raster pipeline has stopped producing frames while the rest of the app keeps running.
This isolates the failure to the Flutter raster pipeline. Both renderers exhibit it.
What I tried (didn't help)
- Tickling the window frame on
windowDidChangeScreen/windowDidChangeBackingProperties(setFrame(insetBy(dx: 1)) → setFrame(original), debounced 150 ms after the event burst). AppKit processes the frame change (visible in logs), but the raster thread doesn't acquire a fresh drawable — once it's stuck insidenextDrawable, further requests don't help.
Related Flutter issues (adjacent, none exactly matching)
- flutter/flutter#145316 — "Resizing macOS runner by changing screen resolution while maximized leads to freaked out app." Open, P2, reported 2024. Closest in mechanism: display reconfig → permanent freeze. Different trigger (resolution change in a foregrounded app) and no Skia A/B verification, but very plausibly the same underlying bug surfacing in a foregrounded variant.
- flutter/flutter#155977 — "Flutter macOS desktop app freezes after switching applications using Mission Control." Open, P2. Different trigger (Mission Control transition) but the same shape: backgrounded → AppKit state transition → permanent freeze. Workaround there is "use the Dock instead." No fix.
Both of these appear to be different surface manifestations of one underlying problem.
Upstream / cross-language evidence for the mechanism
- zed-industries/zed#53390 — non-Flutter, non-Skia Metal application describing the exact
[CAMetalLayer nextDrawable]deadlock pathology: GPU pipeline stalls → in-flight drawables remain held →nextDrawableblocks indefinitely → kernel suspends the process → command buffers never complete → circular deadlock. - gfx-rs/gfx#2460 — backgrounded
nextDrawabledeadlock, with concrete renderer-side defenses listed:presentsWithTransaction: YESand using a timednextDrawablevariant. - Apple Developer Forums thread 15102 — the same
nextDrawableblocking behavior, ack'd by Apple as expected behavior the renderer must defend against.
The conclusion from this trio: the Apple-side behavior is documented and won't change. The Flutter macOS embedder needs to defend against it on the rendering side.
Possible engine-side mitigations to consider
CAMetalLayer.presentsWithTransaction = YES(referenced by gfx-rs) — changes the drawable-presentation model so the layer doesn't hand out a drawable that can later become un-presentable. Applies to both Skia and Impeller paths.- Bounded
nextDrawablewait — call the timed variant (nextDrawableWithTimeout:analogue) and let the raster thread escape and retry on timeout instead of blocking forever. - Pause raster-thread frame submission while
NSApp.isActive == falseand resume onapplicationDidBecomeActive. The freeze does not occur in foreground; not submitting frames while backgrounded would eliminate the precondition entirely. This dovetails with the existingapplicationDidEnterBackgroundsemantics on iOS. - Drop and recreate the
CAMetalLayeronwindowDidChangeScreenwhen in background — more invasive but breaks the stuck-drawable hold definitively.
Code sample
Reproduces on any sufficiently-loaded Flutter macOS app. Happy to share a minimal repro project (a couple hundred animated widgets is enough) if useful.
Logs
Sanitized excerpt showing the smoking-gun pattern: AppKit main thread alive (heartbeat continuous, ~1 s cadence), Dart isolate alive (post-event tick callbacks landing), no rendering occurring:
[diag] event=windowDidChangeScreen t=… backing=2.0 frame=(0,0,2560,1440) visible=true
[diag] event=didChangeScreenParameters t=… backing=2.0
[diag] post-event tick after=windowDidChangeScreen ← AppKit main thread alive
[diag] post-event tick after=didChangeScreenParameters ← AppKit main thread alive
[diag-dart] post-event tick after=windowDidChangeScreen ← Dart isolate alive
[diag] heartbeat tick=607 t=… ← 1 s
[diag] heartbeat tick=608 t=… ← 2 s
[diag] heartbeat tick=609 t=… ← 3 s
…
[diag] heartbeat tick=674 t=… ← 67 s of perfect 1 Hz heartbeat
with the app visually frozen
and no FrameTiming ever arriving
Resize timed out ← window_manager's onWindowResized
Resize timed out fallback fires — engine never
completes a render cycle
The Skia A/B run produced the same pattern with FLTEnableImpeller=false.
Flutter Doctor
Flutter 3.44.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 924134a44c (4 weeks ago) • 2026-05-29 12:13:22 -0400
Engine • hash 39b1f7043775b9578bbb26a1676e79c4e31c8b5e (revision c416acfeb8) (26 days ago) • 2026-05-27 20:19:31.000Z
Tools • Dart 3.12.1 • DevTools 2.57.0
- macOS 26.5.1 (build 25F80)
- Apple Silicon (arm64), MacBook Air M2
- Reproduces with
FLTEnableImpeller=true(Impeller) andFLTEnableImpeller=false(Skia) — confirmed identical behavior - External display: 2560×1440 @ scale 2.0, connected via USB-C
- Running with merged UI and platform thread (the macOS default since Flutter 3.35)
Activity
mbcorona commentedon Jun 24, 2026
Hi @LiorDagan , thanks for the detailed report, outing to the macos team for further investigation.
hellohuanlin commentedon Jul 9, 2026
From triage: cc @knopp in case you have any thoughts? Thanks
knopp commentedon Jul 10, 2026
We're not using CAMetalLayer on macOS. How do you know that "raster thread is wedged"? The macOS binary is fully symbolicated can you provide a stacktrace showing this?
Could this be #188772 instead?
okorohelijah commentedon Jul 16, 2026
Closing as a duplicate of #155977. Feel free to re-open if you think it isnt a duplicate and provide the stack trace
github-actions commentedon Jul 30, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.