feat: mitigate Flutter 3.29+ merged platform/UI thread regression#10
Open
feat: mitigate Flutter 3.29+ merged platform/UI thread regression#10
Conversation
…gression Captures the analysis and proposed migration path for issue Dev-hwang#352. Flutter 3.29 merged the UI and platform threads and Flutter 3.38 removed the opt-out flags, which defeats the plugin's secondary FlutterEngine as a way to run Dart work off the UI thread. The document proposes a TaskExecutionMode option (dedicatedEngine / mergedEngine / backgroundIsolate) with graceful fallback, a phased implementation plan, testing strategy, and forward-looking hooks for the unshipped upstream APIs (dart-lang/sdk#46943, flutter/flutter#176053).
Introduces `TaskExecutionMode` to give callers explicit control over how `TaskHandler` is executed in the context of the merged platform/UI thread change that landed in Flutter 3.29 and became the default in Flutter 3.32. New enum values - `mergedEngine` (default) – secondary `FlutterEngine` multiplexed on the main platform thread, matching Flutter 3.29+ behaviour. - `dedicatedEngine` – requests a separate OS thread via the platform opt-out flag (`DisableMergedPlatformUIThread` on Android, `FLTEnableMergedPlatformUIThread=false` on iOS); logs a clear warning and falls back to `mergedEngine` when the flag is missing or ineffective (Flutter 3.38+). - `backgroundIsolate` – reserved for a future `Isolate.spawn`-based implementation; currently falls back to `mergedEngine` with a warning. Platform changes - `MergedThreadOptOutDetector` on Android (Kotlin) and iOS (Swift) reads the respective opt-out flag and emits a one-shot warning on downgrade. - `ForegroundTask` on both platforms accepts `requestedExecutionMode` and resolves `effectiveExecutionMode` at init. - `ForegroundTaskOptions` persists `executionMode` through `SharedPreferences` (Android) and `UserDefaults` (iOS). - `MethodCallHandlerImpl` / `SwiftFlutterForegroundTaskPlugin` and `ForegroundTask` (both platforms) implement a `debugThreadId` handler returning the OS thread id of the method-channel-handling thread. Dart changes - New `lib/models/task_execution_mode.dart` with `rawValue` / `fromRawValue`. - `ForegroundTaskOptions.executionMode` field (default `mergedEngine`), serialised in `toJson()` and forwarded through `copyWith()`. - `FlutterForegroundTaskPlatform.debugThreadId()` abstract method. - `MethodChannelFlutterForegroundTask.debugThreadId()` prefers the background channel (TaskHandler isolate) and falls back to the main channel (UI isolate). - `FlutterForegroundTask.debugThreadId()` static convenience accessor. Tests - `TaskExecutionMode` round-trip, fallback, and wire-format stability. - `ForegroundTaskOptions` default mode, `toJson`, and `copyWith`. - `debugThreadId` routing: bg-channel preference, main-channel fallback, null when neither responds. - Platform-interface `UnimplementedError` default. Docs - New `documentation/threading_model.md`. - README "More Documentation" link added. - CHANGELOG Unreleased section updated.
The MethodChannel-based debugThreadId() always measured the platform
main thread (every FlutterEngine shares the same platform task runner on
Android/iOS), making it impossible to distinguish mergedEngine from
dedicatedEngine. This replaces the native handlers with an in-isolate
probe via package:universal_ffi/ffi.dart:
- Android: gettid() from libc.so
- iOS/macOS: pthread_mach_thread_np(pthread_self()) via DynamicLibrary.process()
- Other platforms: null
The probe initialization is async (universal_ffi requires it on Android)
and cached after first call. Platform detection is moved to
package:platform/platform.dart (already a direct dependency) instead of
dart:io. Removed universal_io; added universal_ffi to pubspec.yaml.
Stale native debugThreadId handlers removed from:
- MethodCallHandlerImpl.kt
- service/ForegroundTask.kt
- SwiftFlutterForegroundTaskPlugin.swift
- service/ForegroundTask.swift
Documentation updated:
- merged_platform_ui_thread_mitigation.md: new §2 "Known bugs" section
listing this as Bug #1 with root cause, symptom, and fix plan; all
downstream sections renumbered; risk table row marked resolved.
- threading_model.md: runtime verification section updated from
"proposed" to "implemented"; old MethodChannel history preserved.
- CHANGELOG.md: debugThreadId entry revised to reflect FFI semantics.
Tests updated: debugThreadId group now asserts the helper never hits
either MethodChannel, returns a positive tid on supported hosts, and
is stable across calls on the same isolate.
…sue Dev-hwang#352 feedback Rework the TaskExecutionMode.backgroundIsolate design in the mitigation plan to match the feedback from @srmncnk on Dev-hwang#352: - Keep the secondary FlutterEngine instead of dropping it, so plugins stay registered and the foreground service continues to own the process lifetime. Inside the engine's Dart bootstrap, spawn a nested pure Dart Isolate that hosts TaskHandler on a thread from the Dart VM pool, separate from the UI thread. - Adopt NativeCallable.listener as the callback-delivery mechanism for plugins that today rely on MethodChannel.setMessageHandler. This removes the prior "non-starter" constraint and lets backgroundIsolate mode support callback-driven plugins via an opt-in FFI relay. - Confirm FlutterEngineGroup as a non-goal and acknowledge that thread pinning is unavailable until dart-lang/sdk#46943 ships. The plan doc gains a new section 2.5 capturing each feedback point with its resolution status, and sections 5, 6, 7.2.1, 7.2.2, 7.3, 8 (Phase 2), 10, and 12 are updated to reflect the revised topology and the relay. TaskExecutionMode.backgroundIsolate's dartdoc and threading_model.md are updated in lockstep. No runtime behavior change: selecting backgroundIsolate still downgrades to mergedEngine with a one-shot warning until the Phase 2 implementation lands.
Introduce ForegroundTaskCallbackRelay, a thin wrapper around NativeCallable.listener that delivers raw byte payloads from native code into a Dart isolate. This is the first concrete piece of Phase 2 from the merged-platform/UI-thread mitigation plan (\u00a72.5.4 / \u00a77.3): it is the callback-delivery mechanism that the upcoming TaskExecutionMode .backgroundIsolate mode relies on for setMessageHandler-style events, since BackgroundIsolateBinaryMessenger cannot deliver those directly (flutter/flutter#119207). The relay is self-contained and usable today from any isolate -- plugin authors can adopt it independently of the backgroundIsolate dispatcher once that lands. The dartdoc on ForegroundTaskCallbackRelaySignature documents the buffer-lifetime contract imposed by NativeCallable's asynchronous listener dispatch: the native caller must keep the buffer alive until Dart has had a chance to dispatch the event, typically by allocating per-event or retaining a long-lived buffer. Unit tests cover (a) defensive copying so Dart's Uint8List outlives the native buffer, (b) zero-length and null-pointer edge cases, (c) cross-isolate delivery simulating a foreign native thread, and (d) close() idempotency. ffi: ^2.1.0 is added as a dev_dependency solely for calloc in tests; the library itself uses only dart:ffi.
…se 2, gated) Add the Dart half of TaskExecutionMode.backgroundIsolate from the mitigation plan. The runtime still downgrades backgroundIsolate to mergedEngine until the Android / iOS native plumbing is flipped on device, so this commit is behaviour-neutral for existing users; it installs the plumbing the native patches will land against. New: * lib/utils/background_isolate_bootstrap.dart - foregroundTaskBackgroundIsolateBootstrap: @pragma(vm:entry-point) entrypoint the native side will invoke inside the secondary FlutterEngine in place of the user's startCallback. - Bootstrap isolate owns the background MethodChannel, captures RootIsolateToken.instance, requests the user's callback handle from native via a new getUserCallbackHandle method call, then Isolate.spawn(_taskIsolateEntry, ...) into a nested pure Dart isolate on a separate Dart VM thread. - _taskIsolateEntry inside the nested isolate calls BackgroundIsolateBinaryMessenger.ensureInitialized with the bootstrap's token, flips an isolate-local flag, registers a BackgroundTaskDispatcher, and invokes the user's original callback via PluginUtilities.getCallbackFromHandle. - SendPort-based lifecycle protocol (onStart / onRepeatEvent / onDestroy / onReceiveData / onNotificationButtonPressed / ...) with reply ports for the calls that must await completion, so native MethodChannel callers still get correct awaitable semantics through the forwarding layer. - Queues early events until the user's setTaskHandler has attached a handler, so lifecycle events cannot race startup. - Surfaces TaskHandler exceptions through a reply envelope that the bootstrap rethrows as PlatformException to native. Modified: * lib/flutter_foreground_task_method_channel.dart - setTaskHandler now branches: if isInBackgroundTaskIsolate, attach the handler to the active dispatcher (SendPort transport) instead of installing a MethodChannel handler that would be unreachable from the nested isolate. * lib/models/service_options.dart - ServiceStartOptions.toJson and ServiceUpdateOptions.toJson include bootstrapCallbackHandle whenever executionMode is backgroundIsolate, computed via PluginUtilities.getCallbackHandle on foregroundTaskBackgroundIsolateBootstrap. The existing callbackHandle key still carries the user's handle so the bootstrap can hand it to the nested isolate. Tests (234 / 234 green, +14 new): * test/background_isolate_bootstrap_test.dart exercises the dispatcher SendPort protocol, the setTaskHandler routing, the early-event queueing, the UTC timestamp reconstruction, and the TaskHandler-exception surfacing, via a debug harness that runs the dispatcher on the same isolate as the test. * test/service_options_test.dart pins the bootstrap-handle payload behaviour across all three execution modes and for ServiceUpdateOptions. Docs: * CHANGELOG: two new entries under Unreleased (the relay and this scaffolding), both flagged as not changing runtime behaviour yet. * documentation/native_callback_relay.md documents the relay public API, the buffer-lifetime contract, and Kotlin / Swift sketches for hooking it up from native code. The Android / iOS native flip-on (executing bootstrapCallbackHandle instead of downgrading when executionMode == backgroundIsolate) is intentionally NOT in this commit: it requires on-device verification which I cannot run here, and doing it blind would ship broken native code. That patch is the natural follow-up once this Dart layer is reviewed.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
TaskExecutionMode(mergedEngine/dedicatedEngine/backgroundIsolate) onForegroundTaskOptions, giving callers explicit control over how the backgroundFlutterEngineis threaded in the face of the Flutter 3.29+ platform/UI thread merge.MergedThreadOptOutDetectoron Android and iOS: whendedicatedEngineis requested but the platform opt-out flag is absent, the plugin logs a one-shot warning and safely falls back tomergedEngineinstead of silently degrading.FlutterForegroundTask.debugThreadId()— a debug helper that returns the OS thread id of the method-channel-handling thread in the calling isolate, so developers can verify whether true thread isolation is in effect.What changed
lib/models/task_execution_mode.dart(new),lib/models/foreground_task_options.dart,lib/flutter_foreground_task.dart,lib/flutter_foreground_task_method_channel.dart,lib/flutter_foreground_task_platform_interface.dartTaskExecutionMode.kt(new),MergedThreadOptOutDetector.kt(new),ForegroundTaskOptions.kt,ForegroundTask.kt,FlutterForegroundServiceBase.kt,MethodCallHandlerImpl.kt,PreferencesKey.ktTaskExecutionMode.swift(new),MergedThreadOptOutDetector.swift(new),ForegroundTaskOptions.swift,ForegroundTask.swift,BackgroundService.swift,SwiftFlutterForegroundTaskPlugin.swift,PreferencesKey.swifttest/models_test.dart,test/method_channel_test.dart,test/platform_interface_test.dartdocumentation/threading_model.md(new),README.md,CHANGELOG.mdExecution modes
mergedEngine(default)FlutterEnginemultiplexed on the platform/UI thread — matches Flutter 3.29+ default.dedicatedEngineDisableMergedPlatformUIThread(Android) /FLTEnableMergedPlatformUIThread=false(iOS) to get a separate OS thread. Warns + falls back tomergedEnginewhen the flag is absent or ineffective (Flutter 3.38+).backgroundIsolatemergedEnginewith a warning until upstreamBackgroundIsolateBinaryMessengersupportssetMessageHandler.Test plan
flutter test— 215 tests pass, 0 failures.flutter analyze— no issues.TaskExecutionModeraw-value round-trip and wire-format stability tests.ForegroundTaskOptions.executionModedefault,toJson, andcopyWithtests.debugThreadIdrouting tests (bg-channel preference, main-channel fallback, null when neither responds).UnimplementedErrordefault test.Related