Skip to content

Run Headless FlutterEngine in iOS app service extensions - #181645

Closed
Gibbo97 wants to merge 6 commits into
flutter:masterfrom
Gibbo97:run-headless-FlutterEngine-in-iOS-App-Service-Extension
Closed

Gibbo97 wants to merge 6 commits into
flutter:masterfrom
Gibbo97:run-headless-FlutterEngine-in-iOS-App-Service-Extension

Conversation

@Gibbo97

@Gibbo97 Gibbo97 commented Jan 29, 2026 •

Copy link
Copy Markdown
Contributor

This PR aims to allow the flutter engine to run from iOS service extensions. We have an "offline first" app which has extensive dart code to manage state in an SQLite database among other things. We want to reuse this code and run it from a Notification Service Extension before a notification is delivered, to ensure the app has the data the user is being notified about. Service extensions like this are a common feature.

When allowHeadlessExecution: true is set, the Flutter engine was still trying to use the merged platform/UI thread optimization. In notification service extensions, the platform's CFRunLoop isn't being pumped, causing the engine to hang.

Existing Issue: #165904

Example project https://github.com/Gibbo97/flutter_headless_engine_example
In the example when a notification is sent to the device, the method "dispatcher" in main.dart is called from NotificationService.swift, and the print statement runs successfully.
Without the engine changes this statement is never run.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on [Discord].

@Gibbo97
Gibbo97 requested a review from a team as a code owner January 29, 2026 03:57
@flutter-dashboard

Copy link
Copy Markdown

It looks like this pull request may not have tests. Please make sure to add tests or get an explicit test exemption before merging.

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. If you believe this PR qualifies for a test exemption, contact "@test-exemption-reviewer" in the #hackers channel in Discord (don't just cc them here, they won't see it!). The test exemption team is a small volunteer group, so all reviewers should feel empowered to ask for tests, without delegating that responsibility entirely to the test exemption group.

@google-cla

google-cla Bot commented Jan 29, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels Jan 29, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies the thread creation logic for the Flutter engine on iOS to ensure a separate UI thread is always created when allowHeadlessExecution is enabled. This prevents the engine from hanging in environments like notification service extensions where the platform's run loop is not actively pumped. The changes involve updating MakeThreadHost to consider allowHeadlessExecution and adjusting the logic for selecting the UI task runner in createShell. The changes are logical and address the issue, though there is an opportunity to unify the decision-making logic to improve maintainability.

Comment on lines +917 to +920
bool useMergedThread =
settings.enable_impeller &&
settings.merged_platform_ui_thread == flutter::Settings::MergedPlatformUIThread::kEnabled &&
!self.allowHeadlessExecution;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While this logic is correct, the decision to use a merged thread is now present in two places: here and in MakeThreadHost. This duplication could lead to inconsistencies in the future.

To improve clarity and maintainability, consider consolidating this logic. You could define useMergedThread before calling MakeThreadHost and then pass !useMergedThread to it to decide whether to create the UI thread. This would unify the decision-making into a single place.

For example:

// In -[FlutterEngine createShell:...]

// ... before calling MakeThreadHost
const bool useMergedThread =
    settings.enable_impeller &&
    settings.merged_platform_ui_thread == flutter::Settings::MergedPlatformUIThread::kEnabled &&
    !self.allowHeadlessExecution;

NSString* threadLabel = [FlutterEngine generateThreadLabel:self.labelPrefix];
_threadHost = std::make_shared<flutter::ThreadHost>();
// MakeThreadHost would need to be adjusted to take a boolean indicating if a UI thread should be created.
*_threadHost = MakeThreadHost(threadLabel, settings, !useMergedThread);

// ...

if (useMergedThread) {
  ui_runner = fml::MessageLoop::GetCurrent().GetTaskRunner();
} else {
  ui_runner = _threadHost->ui_thread->GetTaskRunner();
}

This would also resolve a potential pre-existing issue where a crash could occur if enable_impeller is false while merged_platform_ui_thread is enabled.

References
  1. Code should avoid duplicating state to maintain a single source of truth. The logic for deciding whether to use a merged thread is duplicated, which could be consolidated. (link)
  2. Code should be optimized for readability. Consolidating the logic for thread creation would make the code easier to understand and maintain. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have implemented this change

for iOS service extensions
@Gibbo97
Gibbo97 force-pushed the run-headless-FlutterEngine-in-iOS-App-Service-Extension branch from 401a53b to 4d9d673 Compare January 29, 2026 06:10

@gaaclarke gaaclarke left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds a bit dicy dealing with threading. This PR has no tests, we'll need that as a minimum.

@Gibbo97

Gibbo97 commented Feb 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@gaaclarke

This sounds a bit dicy dealing with threading. This PR has no tests, we'll need that as a minimum.

Absolutely. Probably out of my depth a bit here.

However, I've added one test there which I think gives full coverage?
As far as I can see, this is not modifying the existing logic at all, just bolting on a small new bit to make this (imo) decent use case work.

@gaaclarke gaaclarke left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll let the iOS team review this. Thanks for the test. There is always a tension when increasing the FlutterEngine API but I think you have a good case to be made here.

*
* Defaults to NO.
*/
@property(nonatomic, assign) BOOL isRunningInExtension;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a parameter in init, not a property since setting it after run is meaningless.

@Gibbo97 Gibbo97 Feb 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to shift it to a parameter if that is preferred.

The use is like this:

       let engine = FlutterEngine(
            name: "notification_service",
            project: flutterProject,
            allowHeadlessExecution: true
        )
        engine.isRunningInExtension = true

        self.flutterEngine = engine

        // Run the dispatcher entrypoint
        let success = engine.run(withEntrypoint: "dispatcher",
                                 libraryURI: nil,
                                 initialRoute: nil,
                                 entrypointArgs: ["notification_service_extension"])

In FlutterEngine.h there is a kind of similar existing example of a settable BOOL property:

  isGpuDisabled (line 441-446):
  /**
   * Whether or not GPU calls are allowed.
   *
   * Typically this is set when the app is backgrounded and foregrounded.
   */
  @property(nonatomic, assign) BOOL isGpuDisabled;

*
* When set to YES, the engine will use a separate UI thread instead of merging with the platform
* thread. This is necessary in extension contexts where the platform's CFRunLoop is not being
* actively pumped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "pump" mean.

Could you also add details on why this is necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, this is the first time I've worked on this so it's a lot for me to wrap my head around.

In a Service Extension the Platform Thread is not being looped through. In a normal iOS app UIApplicationMain loops indefinitely.

In the engine there is a check (which I have modified) which sees if it should merge the UI Thread into the Platform thread. If they are merged the dart code is never run because it is not looped through.

We need some way of forcing the two threads to be seperate if we want it to ever be able to run dart code without the Platform Thread looping. Alternatively we could potentially start manually looping the Platform Thread.

// - Impeller is disabled (merged thread only works with Impeller)
// - merged_platform_ui_thread setting is not enabled
// - Running in an extension (e.g., notification service extension) where the platform's
// CFRunLoop is not being actively pumped

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any apple documentation on this? does this apply to all extensions (e.g. share extension) or is it just for notification service extension

@hellohuanlin

Copy link
Copy Markdown
Contributor

@Gibbo97 from triage: are you able to address the comments? thanks

@flutter-dashboard

Copy link
Copy Markdown

This pull request executed golden file tests, but it has not been updated in a while (20+ days). Test results from Gold expire after as many days, so this pull request will need to be updated with a fresh commit in order to get results from Gold.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

@vashworth

Copy link
Copy Markdown
Contributor

From triage: @Gibbo97 we're converting this to a draft for now to remove it from our triage queue. Please request review again when you're ready!

@vashworth
vashworth marked this pull request as draft March 19, 2026 21:38
@flutter-dashboard

Copy link
Copy Markdown

This pull request has been changed to a draft. The currently pending flutter-gold status will not be able to resolve until a new commit is pushed or the change is marked ready for review again.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

@Gibbo97

Gibbo97 commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

I have decided to close this PR as I came up with a better solution that does not need the engine changes. Writeup and sample project here: https://medium.com/top-rail/flutter-and-notification-service-extensions-d88c35e4bfb9

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

Labels

engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants