Skip to content

Headless FlutterEngine started off the main thread on iOS crashes on a DisplayLinkManager main-thread assertion #190909

Closed

Description

@rajveermalviya

Steps to reproduce

  1. Create a scratch app with: flutter create scratch_app
  2. Replace ios/Runner/AppDelegate.swift with the Code sample below. It starts a headless FlutterEngine on a background queue and blocks until run() returns.
  3. Checkout Flutter SDK to f65eec9, then flutter run on an iOS device or Simulator.
  4. Notice that the app launches and headless engine started: true is logged.
  5. Checkout Flutter SDK to 7b6f0d4 ([iOS] Migrate DisplayLinkManager to a shared instance #189492), then flutter run again.
  6. Notice that the app crashes on an assertion in DisplayLinkManager.init().

Expected results

The headless FlutterEngine starts and runs its Dart entrypoint, as it does on the parent commit.

Actual results

The process crashes with EXC_BREAKPOINT (SIGTRAP) on this assertion:

InternalFlutterSwift/DisplayLinkManager.swift:94: Assertion failed: DisplayLinkManager.shared must first be accessed on the main thread.

The two commits in the steps above are #189492 and its parent, so that's where this starts.

Running the same headless-engine code directly on the main thread launches cleanly, so the trigger is starting the engine off the main thread.

We hit this in a UNNotificationServiceExtension that runs a headless engine to rewrite notification content. Its didReceive(_:withContentHandler:) is called on com.apple.NSXPCConnection.user.endpoint rather than the main thread, so the extension crashes before it can call contentHandler, and iOS delivers the original, unmodified notification payload. The crash there has the same backtrace as the sample below.

Our app is opensource, you can look at the related code here: https://github.com/zulip/zulip-flutter/blob/5b11117123438dddeba0555006e197db5be06f71/ios/NotificationService/NotificationService.swift#L33

Only tested in a debug build.

Code sample

Code sample
// ios/Runner/AppDelegate.swift
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let semaphore = DispatchSemaphore(value: 0)
    DispatchQueue.global().async {
      let engine = FlutterEngine(name: "headless", project: nil, allowHeadlessExecution: true)
      let started = engine.run()
      NSLog("headless engine started: \(started)")
      semaphore.signal()
    }
    semaphore.wait()

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
  }
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

On f65eec9 (parent commit) — works
$ flutter run
Launching lib/main.dart on iPhone 17 Pro in debug mode...
Running Xcode build...
Xcode build done.                                            9.7s
[IMPORTANT:flutter/shell/platform/darwin/graphics/FlutterDarwinContextMetalImpeller.mm(45)] Using the Impeller rendering backend (Metal).
[IMPORTANT:flutter/shell/platform/darwin/graphics/FlutterDarwinContextMetalImpeller.mm(45)] Using the Impeller rendering backend (Metal).
Syncing files to device iPhone 17 Pro...                            39ms

Flutter run key commands.
...
A Dart VM Service on iPhone 17 Pro is available at: http://127.0.0.1:53326/tTrDs8gOC0A=/

The app logs headless engine started: true.

On 7b6f0d4 — crashes
$ flutter run
Launching lib/main.dart on iPhone 17 Pro in debug mode...
Running Xcode build...
Xcode build done.                                            9.6s
[IMPORTANT:flutter/shell/platform/darwin/graphics/FlutterDarwinContextMetalImpeller.mm(45)] Using the Impeller rendering backend (Metal).
InternalFlutterSwift/DisplayLinkManager.swift:94: Assertion failed: DisplayLinkManager.shared must first be accessed on the main thread.
Crash report backtrace (from the Notification Service extension)
Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Termination:     Trace/BPT trap: 5
Triggered by Thread:  1  (queue: com.apple.NSXPCConnection.user.endpoint)

Thread 1 Crashed:
0   libswiftCore.dylib     _assertionFailure(_:_:file:line:flags:)
1   Flutter                DisplayLinkManager.().init()
2   Flutter                @objc DisplayLinkManager.().init()
3   Flutter                DisplayLinkManager.__allocating_init()
4   Flutter                one-time initialization function for shared
5   libdispatch.dylib      _dispatch_client_callout
6   libdispatch.dylib      _dispatch_once_callout
7   Flutter                DisplayLinkManager.shared.unsafeMutableAddressor
8   Flutter                static DisplayLinkManager.shared.getter
9   Flutter                @objc static DisplayLinkManager.shared.getter
10  Flutter                flutter::VsyncWaiterIOS::VsyncWaiterIOS(flutter::TaskRunners const&)
11  Flutter                flutter::PlatformViewIOS::CreateVSyncWaiter()
12  Flutter                flutter::Shell::CreateShellOnPlatformThread(...)
13  Flutter                std::__function::__func<fml::internal::CopyableLambda<flutter::Shell::CreateWithSnapshot(...)
14  Flutter                flutter::Shell::CreateWithSnapshot(...)
15  Flutter                flutter::Shell::Create(...)

Flutter Doctor output

Doctor output
$ flutter doctor -v
[✓] Flutter (Channel master, 3.47.0-1.0.pre-92, on macOS 26.5.2 25F84 darwin-arm64, locale en-IN) [2.2s]
    • Flutter version 3.47.0-1.0.pre-92 on channel master at /Users/rajveer/sdk/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 7b6f0d41c0 (4 weeks ago), 2026-07-16 06:51:46 +0900
    • Engine revision 7b6f0d41c0
    • Dart version 3.14.0 (build 3.14.0-29.0.dev)
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, no-enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration, enable-riscv64

[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,930ms]
    • Android SDK at /Users/rajveer/Library/Android/sdk
    • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
    • Platform android-36.1, build-tools 36.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 26.6) [658ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 17F113
    • CocoaPods version 1.17.0

[✓] Chrome - develop for the web [3ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (4 available) [5.7s]
    • Rajesh’s iPhone (wireless) (mobile) • 00008030-000149CA1A04802E            • ios            • iOS 26.6 23G71
    • iPhone 17 Pro (mobile)              • 596CCF08-FDD2-49C2-8ED1-1699A75CF8B9 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator)
    • macOS (desktop)                     • macos                                • darwin-arm64   • macOS 26.5.2 25F84 darwin-arm64
    • Chrome (web)                        • chrome                               • web-javascript • Google Chrome 151.0.7922.76

[✓] Network resources [494ms]
    • All expected network resources are available.

• No issues found!

Activity

  1. rajveermalviya commented on Aug 11, 2026

    @rajveermalviya
    ContributorAuthor

    cc @cbracken, as the author of the related PR (#189492).

  2. suryaKommana2662 commented on Aug 11, 2026

    @suryaKommana2662
    Contributor

    @rajveermalviya Thanks for the detailed report and regression analysis. The issue includes a minimal reproduction, mentioned exact working commit(f65eec9) and failing Flutter commit(7b6f0d4), a clear assertion failure, and a complete crash backtrace. Routing to the team-ios for further investigation.

  3. added
    platform-iosiOS applications specifically
    engineflutter/engine related. See also e: labels.
    team-iosOwned by iOS platform team
    c: regressionIt was better in the past than it is now
    and removed
    in triagePresently being triaged by the triage team
    on Aug 11, 2026
  4. added
    P1High-priority issues at the top of the work list
    on Aug 13, 2026
  5. self-assigned this
    on Aug 13, 2026
  6. cbracken commented on Aug 13, 2026

    @cbracken
    Member

    @rajveermalviya thanks for the report and bisection.

    Related #190725, which I think would formalise exactly the constraint that is causing the breakage here. We're going to need to make a call on whether we want to take a lock-based approach that's thread safe but won't play nice with strict Swift concurrency or try to push handling of this specific case to the engine. (iOS triage meeting)

    /cc @hellohuanlin @LongCatIsLooong

  7. cbracken commented on Aug 13, 2026

    @cbracken
    Member

    As a little bit of background; the iOS embedder binds the platform thread to whatever thread calls run. Calling [FlutterEngine run] from a background queue makes the background thread the (merged) platform + UI thread.

    When we create the Shell we then call PlatformViewIOS::CreateVSyncWaiter which creates DisplayLinkManager.shared, or before #189492 called static methods on that class. That code called Bundle.main and UIScreen.main on every access, which were straight up unsafe, unsynchronised UIKit reads, or to quote the PR description "nothing guaranteed these accessors weren't being called from a background engine thread, which would be a UIKit thread-safety violation."

    The UIScreen.main, NotificationCenter observers, CADisplayLink runloop registration, keyboard/viewcontroller wiring, and (post UI/Platform-thread-merge) the Dart execution context all assume they're running on the iOS main thread.

    #189492 added a thread check to ensure that we are safely setting up on the correct thread. This is definitely the right thing to do and we shouldn't roll that back; this isn't a regression. It's intended behaviour to align with iOS/Flutter thread safety rules.

    That said, I think there are a couple things we should do here:

    1. This should be clearly documented. createShell should have an FML_CHECK (all build modes, not just debug) with a clear error message like "[FlutterEngine run] must be called on the main queue", and we should document this clearly in the doc comment for run.
    2. We shouldn't built a vsync waiter for a headless engine, or at least make this lazy such that we don't instantiate DisplayLinkManager until we have something that actually needs a vsync.

    Doing 2 alone would in theory "unbreak" this case, but we'd still be allowing the engine to be built in an unsupported way. I suspect the right solution is to lock this down and prevent it from being constructed unsafely. It's not a runtime mode most people working on the engine/embedder has in mind and it's already likely broken/racy in subtle ways today (hence the patch above). A quick peek at Zulip suggests you've already hit the fact that no one is pumping the background thread you're on and need to do it yourselves (and TODO referencing #181645).

    Before we decide how to deal with this, I want to experiment with it a bit from the app perspective to see how easy/painful the fix will be for apps. Will play with Zulip and see what a fix looks like and we can look at the PR.

    I suspect 99% of apps won't hit this. You're got to be an app that wants to do processing in Dart on a headless engine on a background thread.

    Regardless, we should provide much clearer docs around this. Right now the only place it's documented AFAICT is at:

    static flutter::ThreadHost MakeThreadHost(NSString* thread_label) {
    // The current thread will be used as the platform thread. Ensure that the message loop is
    // initialized.
    fml::MessageLoop::EnsureInitializedForCurrentThread();
    // No dedicated UI thread is created: on iOS the UI thread is always merged onto the platform
    // thread. FlutterDartProject rejects any other threading configuration at startup.
    uint32_t threadHostType = flutter::ThreadHost::Type::kRaster | flutter::ThreadHost::Type::kIo;

  8. cbracken commented on Aug 13, 2026

    @cbracken
    Member

    @rajveermalviya I'll take a look at your app tomorrow and see if I can play with a workaround. I think it would be informative for how we proceed with this.

    Thanks again for filing the issue and for linking to the code.

    Edit: Ah -- it's Zulip! Nice.

  9. rajveermalviya commented on Aug 13, 2026

    @rajveermalviya
    ContributorAuthor

    Hi @cbracken, thanks for investigating this!

    Some background on why we run an engine this way. Zulip's iOS NotificationService AppExtension uses a headless FlutterEngine to run Dart code, so it can mutate an incoming push notification before it's displayed, in our case, to decrypt the encrypted payload the Zulip server sends, as part of supporting E2EE push notifications. We put it together partly from https://docs.flutter.dev/platform-integration/ios/app-extensions, though that page doesn't cover NotificationService AppExtension specifically.

    On Android, package:firebase_messaging does the equivalent for us: it starts a FlutterEngine in the background and passes the notification payload to Dart. The NotficationService AppExtension is how we mimic that on iOS, which lets Zulip's push notification handling be a single cross-platform Dart implementation.

    So AIUI, something along the lines of your point 2 for FlutterEngine created with allowHeadlessExecution: true, would be really helpful.

    Testing this code path is a bit involved, reaching this path at all takes a real push notification with the
    right payload structure to trigger the extension, and for zulip-flutter that means running a dev Zulip server:
    https://github.com/zulip/zulip-flutter/blob/main/docs/howto/push-notifications.md#testing-client-side-changes-on-ios

    I can instead put together a separate sample app, though sending real pushes to it would need an Apple Developer account to set up notifications, and trigger them via the console. Happy to do that if it would help.

  10. added
    triaged-iosTriaged by iOS platform team
    P2Important issues not at the top of the work list
    and removed
    P1High-priority issues at the top of the work list
    on Aug 13, 2026
  11. hellohuanlin commented on Aug 18, 2026

    @hellohuanlin
    Contributor

    Zulip's iOS NotificationService AppExtension uses a headless FlutterEngine to run Dart code, so it can mutate an incoming push notification before it's displayed, in our case, to decrypt the encrypted payload the Zulip server sends, as part of supporting E2EE push notifications.

    @rajveermalviya this sounds like you can easily hop between threads, to ensure to run flutter engine APIs on main thread only. Have you tried it?

  12. rajveermalviya commented on Aug 31, 2026

    @rajveermalviya
    ContributorAuthor

    With #191626 the error messages are much clearer, thanks @cbracken!

    And thanks for the suggestion, @hellohuanlin: doing the FlutterEngine init, and related API calls on the main thread fixes this for us.

    Feel free to close this issue.

  13. hellohuanlin commented on Sep 2, 2026

    @hellohuanlin
    Contributor

    With #191626 the error messages are much clearer, thanks [@cbracken](https://github.com/cbracken)!

    And thanks for the suggestion, [@hellohuanlin](https://github.com/hellohuanlin): doing the FlutterEngine init, and related API calls on the main thread fixes this for us.

    Feel free to close this issue.

    SG. Closing

  14. github-actions commented on Sep 16, 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.

  15. locked as resolved and limited conversation to collaborators on Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listc: regressionIt was better in the past than it is nowengineflutter/engine related. See also e: labels.platform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions