Repository navigation
Description
Steps to reproduce
- Create a scratch app with:
flutter create scratch_app - Replace
ios/Runner/AppDelegate.swiftwith the Code sample below. It starts a headlessFlutterEngineon a background queue and blocks untilrun()returns. - Checkout Flutter SDK to f65eec9, then
flutter runon an iOS device or Simulator. - Notice that the app launches and
headless engine started: trueis logged. - Checkout Flutter SDK to 7b6f0d4 ([iOS] Migrate DisplayLinkManager to a shared instance #189492), then
flutter runagain. - 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
rajveermalviya commentedon Aug 11, 2026
cc @cbracken, as the author of the related PR (#189492).
suryaKommana2662 commentedon Aug 11, 2026
@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-iosfor further investigation.cbracken commentedon Aug 13, 2026
@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
cbracken commentedon Aug 13, 2026
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
Shellwe then callPlatformViewIOS::CreateVSyncWaiterwhich createsDisplayLinkManager.shared, or before #189492 called static methods on that class. That code calledBundle.mainandUIScreen.mainon 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:
createShellshould have anFML_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 forrun.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:
flutter/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm
Lines 835 to 842 in a93c97a
cbracken commentedon Aug 13, 2026
@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.
cbracken commentedon Aug 13, 2026
Related:
rajveermalviya commentedon Aug 13, 2026
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_messagingdoes 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.
hellohuanlin commentedon Aug 18, 2026
@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?
iOS,macOS: Compile Swift with opt flags in opt builds
iOS,macOS: Compile Swift with opt flags in opt builds
iOS: Ensure engine is run on the main thread (flutter#191626)
iOS,macOS: Compile Swift with opt flags in opt builds (flutter#191622)
rajveermalviya commentedon Aug 31, 2026
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.
hellohuanlin commentedon Sep 2, 2026
SG. Closing
github-actions commentedon Sep 16, 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.