Skip to content

[macOS] Raster thread permanently wedges in CAMetalLayer when backgrounded app loses host display #188493

Closed as duplicate

Description

@LiorDagan

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:

  1. 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 MaterialApp or "hello world" scaffold does not reproduce; the trigger needs real GPU work in flight at the moment of display loss.
  2. Move the window onto an external display (in my repro: 2560×1440 @ scale 2.0, mirrored via USB-C).
  3. Give focus to a different app (the Flutter app must not be the active app — NSApp.isActive == false).
  4. Close the laptop lid (MacBook Air M2 in my case).
  5. 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 NSTimer 1 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, with DispatchQueue.main.async follow-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 inside nextDrawable, 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 → nextDrawable blocks indefinitely → kernel suspends the process → command buffers never complete → circular deadlock.
  • gfx-rs/gfx#2460 — backgrounded nextDrawable deadlock, with concrete renderer-side defenses listed: presentsWithTransaction: YES and using a timed nextDrawable variant.
  • Apple Developer Forums thread 15102 — the same nextDrawable blocking 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

  1. 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.
  2. Bounded nextDrawable wait — call the timed variant (nextDrawableWithTimeout: analogue) and let the raster thread escape and retry on timeout instead of blocking forever.
  3. Pause raster-thread frame submission while NSApp.isActive == false and resume on applicationDidBecomeActive. The freeze does not occur in foreground; not submitting frames while backgrounded would eliminate the precondition entirely. This dovetails with the existing applicationDidEnterBackground semantics on iOS.
  4. Drop and recreate the CAMetalLayer on windowDidChangeScreen when 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) and FLTEnableImpeller=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

  1. added
    c: crashStack traces logged to the console
    platform-macosBuilding on or for macOS specifically
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-macosOwned by the macOS platform team
    on Jun 24, 2026
  2. mbcorona commented on Jun 24, 2026

    @mbcorona
    Member

    Hi @LiorDagan , thanks for the detailed report, outing to the macos team for further investigation.

  3. hellohuanlin commented on Jul 9, 2026

    @hellohuanlin
    Contributor

    From triage: cc @knopp in case you have any thoughts? Thanks

  4. knopp commented on Jul 10, 2026

    @knopp
    Member

    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?

  5. removed
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    on Jul 13, 2026
  6. okorohelijah commented on Jul 16, 2026

    @okorohelijah
    Contributor

    Closing as a duplicate of #155977. Feel free to re-open if you think it isnt a duplicate and provide the stack trace

  7. github-actions commented on Jul 30, 2026

    @github-actions

    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 -v and a minimal reproduction of the issue.

  8. locked as resolved and limited conversation to collaborators on Jul 30, 2026
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

    a: desktopRunning on desktopc: crashStack traces logged to the consolec: renderingUI glitches reported at the engine/skia or impeller rendering levelfound in release: 3.44Found to occur in 3.44platform-macosBuilding on or for macOS specificallyteam-macosOwned by the macOS platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions