Skip to content

feat: mitigate Flutter 3.29+ merged platform/UI thread regression#10

Open
ppamorim wants to merge 8 commits intomasterfrom
feature/singleChannelAdapt
Open

feat: mitigate Flutter 3.29+ merged platform/UI thread regression#10
ppamorim wants to merge 8 commits intomasterfrom
feature/singleChannelAdapt

Conversation

@ppamorim
Copy link
Copy Markdown

Summary

  • Introduces TaskExecutionMode (mergedEngine / dedicatedEngine / backgroundIsolate) on ForegroundTaskOptions, giving callers explicit control over how the background FlutterEngine is threaded in the face of the Flutter 3.29+ platform/UI thread merge.
  • Adds MergedThreadOptOutDetector on Android and iOS: when dedicatedEngine is requested but the platform opt-out flag is absent, the plugin logs a one-shot warning and safely falls back to mergedEngine instead of silently degrading.
  • Adds 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

Layer Files
Dart 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.dart
Android TaskExecutionMode.kt (new), MergedThreadOptOutDetector.kt (new), ForegroundTaskOptions.kt, ForegroundTask.kt, FlutterForegroundServiceBase.kt, MethodCallHandlerImpl.kt, PreferencesKey.kt
iOS TaskExecutionMode.swift (new), MergedThreadOptOutDetector.swift (new), ForegroundTaskOptions.swift, ForegroundTask.swift, BackgroundService.swift, SwiftFlutterForegroundTaskPlugin.swift, PreferencesKey.swift
Tests test/models_test.dart, test/method_channel_test.dart, test/platform_interface_test.dart
Docs documentation/threading_model.md (new), README.md, CHANGELOG.md

Execution modes

Mode Behaviour
mergedEngine (default) Secondary FlutterEngine multiplexed on the platform/UI thread — matches Flutter 3.29+ default.
dedicatedEngine Relies on DisableMergedPlatformUIThread (Android) / FLTEnableMergedPlatformUIThread=false (iOS) to get a separate OS thread. Warns + falls back to mergedEngine when the flag is absent or ineffective (Flutter 3.38+).
backgroundIsolate Reserved; falls back to mergedEngine with a warning until upstream BackgroundIsolateBinaryMessenger supports setMessageHandler.

Test plan

  • flutter test — 215 tests pass, 0 failures.
  • flutter analyze — no issues.
  • TaskExecutionMode raw-value round-trip and wire-format stability tests.
  • ForegroundTaskOptions.executionMode default, toJson, and copyWith tests.
  • debugThreadId routing tests (bg-channel preference, main-channel fallback, null when neither responds).
  • Platform-interface UnimplementedError default test.

Related

…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.
@sonarqubecloud
Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant