diff --git a/workmanager/lib/src/workmanager_impl.dart b/workmanager/lib/src/workmanager_impl.dart index 73588ca8..cf8a9485 100644 --- a/workmanager/lib/src/workmanager_impl.dart +++ b/workmanager/lib/src/workmanager_impl.dart @@ -143,6 +143,12 @@ class Workmanager { static ProgressListener? _progressListener; static late final WorkmanagerFlutterApi _flutterApi; + /// Dart→native API proxy used by background isolates to signal readiness + /// (see [executeTask]). Constructed lazily so the messenger is captured on + /// the isolate that actually runs a background task, after + /// [WidgetsFlutterBinding.ensureInitialized] bound it to the engine. + static late final WorkmanagerHostApi _hostApi = WorkmanagerHostApi(); + /// The callback dispatcher registered via [initialize], kept so in-process /// (main-engine) one-off tasks can lazily register their task handler on /// first execution without spawning a second Flutter engine. @@ -225,7 +231,22 @@ class Workmanager { _flutterApi = _WorkmanagerFlutterApiImpl(); WorkmanagerFlutterApi.setUp(_flutterApi); - await _flutterApi.backgroundChannelInitialized(); + // Signal the native worker running this isolate that the task handlers + // are now registered on this engine's messenger. The native side waits + // for this signal before invoking [WorkmanagerFlutterApi.executeTask]; + // because the signal is sent from Dart *after* setUp, the follow-up + // executeTask call can never race isolate startup (a regression + // introduced by the Pigeon migration, which flipped the handshake to + // native-initiated — see #732/#738). + // + // Deliberately not awaited: engines without a native receiver for this + // call (web, desktop headless) never answer, and the worker must not + // depend on the reply. Errors are swallowed for the same reason. + unawaited( + _hostApi + .notifyBackgroundChannelInitialized() + .then((_) {}, onError: (Object _) {}), + ); } /// Schedule a one-off task. diff --git a/workmanager/test/background_channel_ready_signal_test.dart b/workmanager/test/background_channel_ready_signal_test.dart new file mode 100644 index 00000000..705690a0 --- /dev/null +++ b/workmanager/test/background_channel_ready_signal_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workmanager/workmanager.dart'; +import 'package:workmanager_apple/workmanager_apple.dart'; + +/// Host channel the Dart side uses to signal that its task handlers are +/// registered. Native workers wait for this signal before invoking +/// [WorkmanagerFlutterApi.executeTask], so the handshake never races isolate +/// startup (regression introduced by the Pigeon migration — see #732/#738). +const String _readyChannel = + 'dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi' + '.notifyBackgroundChannelInitialized'; + +/// Fake platform that records initialize() calls without touching real +/// platform channels. Extends [WorkmanagerApple] so Workmanager's platform +/// auto-selection does not replace it on the test host. +class _FakeApplePlatform extends WorkmanagerApple { + @override + Future initialize( + Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') + bool isInDebugMode = false, + }) async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + WorkmanagerPlatform.instance = _FakeApplePlatform(); + }); + + test('executeTask signals readiness and does not wait for the reply', + () async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final codec = WorkmanagerFlutterApi.pigeonChannelCodec; + + var readySignals = 0; + // Simulate a native side that receives the signal but never answers (the + // case on platforms without a native receiver, e.g. desktop headless). + // Recording the invocation is enough; the worker must not depend on the + // reply. + final never = Completer(); + messenger.setMockMessageHandler(_readyChannel, (ByteData? message) { + readySignals++; + expect(message, isNull); + return never.future; + }); + + final executedTasks = []; + void callbackDispatcher() { + Workmanager().executeTask((taskName, inputData) async { + executedTasks.add(taskName); + return true; + }); + } + + await Workmanager().initialize(callbackDispatcher); + expect(readySignals, 0, + reason: 'no signal may be sent before a task executes'); + + callbackDispatcher(); + + // Exactly one readiness signal per executeTask call, sent after the task + // handler was registered (setUp runs before the signal in executeTask). + expect(readySignals, 1); + + // The task handler must still be executable while the signal is + // unanswered: simulate the native side invoking executeTask once the + // signal was sent. + ByteData? reply; + await messenger.handlePlatformMessage( + 'dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerFlutterApi' + '.executeTask', + codec.encodeMessage([ + 'dev.fluttercommunity.test.oneOff', + {'foo': 'bar'}, + ]), + (data) { + reply = data; + }, + ); + expect(reply, isNotNull); + expect(codec.decodeMessage(reply), [true]); + expect(executedTasks, ['dev.fluttercommunity.test.oneOff']); + }); +} diff --git a/workmanager/test/workmanager_test.mocks.dart b/workmanager/test/workmanager_test.mocks.dart index 64826086..6ed3a917 100644 --- a/workmanager/test/workmanager_test.mocks.dart +++ b/workmanager/test/workmanager_test.mocks.dart @@ -3,6 +3,7 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'dart:async' as _i3; import 'package:mockito/mockito.dart' as _i1; diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt index 6d515edd..506b141a 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt @@ -78,6 +78,13 @@ class BackgroundWorker( private var initializationWatchdog: DartInitializationWatchdog? = null + /** + * Guards [executeBackgroundTask] against duplicate Dart readiness signals + * (the Dart side could call notifyBackgroundChannelInitialized more than + * once, e.g. when executeTask runs twice in the same isolate). + */ + private val taskStarted = AtomicBoolean(false) + /** * The plugin instance attached to this worker's engine. The Dart task * runs on that engine, so its `reportProgress` calls arrive at this @@ -177,13 +184,15 @@ class BackgroundWorker( ) // The worker's result future is only resolved once the Dart - // side acknowledges the initialized background channel. If - // the engine fails to start the Dart isolate, or the - // acknowledgement is lost, the worker would otherwise stay - // in the RUNNING state forever (see #732). Arm a watchdog - // that fails the worker when the acknowledgement does not - // arrive in time; it is disarmed on acknowledgement and on - // stop. + // side signals that its background-channel handlers are + // registered. That signal is a Dart→native call + // (WorkmanagerHostApi.notifyBackgroundChannelInitialized), + // routed here by the plugin bound to this worker's engine. If + // the engine fails to start the Dart isolate, or the signal is + // lost, the worker would otherwise stay in the RUNNING state + // forever (see #732). Arm a watchdog that fails the worker + // when the signal does not arrive in time; it is disarmed on + // the signal and on stop. val watchdog = DartInitializationWatchdog( handler = mainHandler, @@ -200,12 +209,14 @@ class BackgroundWorker( initializationWatchdog = watchdog watchdog.arm() - // Initialize the background channel - flutterApi.backgroundChannelInitialized { - // Channel is initialized, now execute the task - watchdog.disarm() - executeBackgroundTask() - } + // The Dart isolate runs the callback dispatcher as its entry + // point; once its task handlers are set up it calls + // notifyBackgroundChannelInitialized, which lands in + // [onDartBackgroundChannelInitialized] and kicks off + // execution. Nothing is sent to Dart from here: a native→Dart + // message sent before the isolate registered its handlers is + // silently lost, which is what left workers stuck in RUNNING + // (pre-0.10.9) or failing the watchdog (0.10.9, #738). } } @@ -387,6 +398,30 @@ class BackgroundWorker( StopReasonUtils.STOP_REASON_UNKNOWN } + /** + * Called by the plugin attached to this worker's engine when the Dart + * isolate signals that its task handlers are registered (the Dart side + * calls `WorkmanagerHostApi.notifyBackgroundChannelInitialized` from + * `Workmanager().executeTask`, after `WorkmanagerFlutterApi.setUp`). + * + * Only now is the task sent down to Dart: because the signal originates + * from Dart, its handlers are guaranteed to be registered when the + * follow-up [WorkmanagerFlutterApi.executeTask] call arrives — the + * handshake can never race isolate startup (see #732/#738). + */ + fun onDartBackgroundChannelInitialized() { + if (isStopped) return + mainHandler.post { + // A stopped or torn-down worker (watchdog fired, WM stop) must + // ignore a late signal. + if (isStopped || engine == null) return@post + if (taskStarted.compareAndSet(false, true)) { + initializationWatchdog?.disarm() + executeBackgroundTask() + } + } + } + private fun executeBackgroundTask() { // Convert payload to the format expected by Pigeon (Map) val pigeonPayload = payload.mapKeys { it.key as String? }.mapValues { it.value as Object? } diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DartInitializationWatchdog.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DartInitializationWatchdog.kt index 2ad5bc9f..cf05e9bf 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DartInitializationWatchdog.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DartInitializationWatchdog.kt @@ -6,11 +6,12 @@ import java.util.concurrent.atomic.AtomicBoolean /** * Bounds the Dart engine initialization handshake of a [BackgroundWorker]. * - * The worker's result future is only resolved once the Dart side acknowledges - * `backgroundChannelInitialized`. If the engine fails to start the Dart - * isolate, or the acknowledgement is lost, the worker would otherwise stay in - * the RUNNING state forever (see #732). This watchdog fires [timeoutAction] - * when the acknowledgement does not arrive within [timeoutMillis]. + * The worker's result future is only resolved once the Dart side signals + * that its background-channel handlers are registered + * (`WorkmanagerHostApi.notifyBackgroundChannelInitialized`). If the engine + * fails to start the Dart isolate, or the signal is lost, the worker would + * otherwise stay in the RUNNING state forever (see #732). This watchdog fires + * [timeoutAction] when the signal does not arrive within [timeoutMillis]. * * [arm] and [disarm] are idempotent and safe to call from any thread: the * action fires at most once, and only while the watchdog is armed. diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt index 75b68254..455cba38 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt @@ -260,6 +260,18 @@ class WorkmanagerPlugin : callback(Result.success(Unit)) } + override fun notifyBackgroundChannelInitialized(callback: (Result) -> Unit) { + val worker = boundWorker + if (worker != null) { + // The signal originates from the Dart isolate running on this + // plugin's engine, which is the engine of the bound worker (or of + // a worker that has not bound itself yet — then there is nothing + // to execute and the signal is ignored). + worker.onDartBackgroundChannelInitialized() + } + callback(Result.success(Unit)) + } + override fun setProgressListener( enabled: Boolean, callback: (Result) -> Unit, diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt index 6e9c60fd..32ade479 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt @@ -1310,6 +1310,23 @@ interface WorkmanagerHostApi { * progress support the call is a no-op. */ fun setProgressListener(enabled: Boolean, callback: (Result) -> Unit) + /** + * Signals the native side that the background isolate's task handlers are + * registered on this engine's messenger. + * + * Called from Dart by `Workmanager().executeTask`, after + * [WorkmanagerFlutterApi]'s handlers have been set up. Native background + * workers wait for this signal before invoking + * [WorkmanagerFlutterApi.executeTask]; because the signal is sent from + * Dart only after setUp completed, the follow-up `executeTask` call can + * never race isolate startup (regression introduced by the Pigeon + * migration, which flipped the handshake to native-initiated — see + * #732/#738). + * + * Platforms and engines that never execute a Dart background task (the app + * engine on Android, web) may ignore the call. + */ + fun notifyBackgroundChannelInitialized(callback: (Result) -> Unit) companion object { /** The codec used by WorkmanagerHostApi. */ @@ -1585,6 +1602,23 @@ interface WorkmanagerHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.notifyBackgroundChannelInitialized$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.notifyBackgroundChannelInitialized{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(WorkmanagerApiPigeonUtils.wrapError(error)) + } else { + reply.reply(WorkmanagerApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift index 567e54a8..bcfaf6ab 100644 --- a/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift @@ -85,6 +85,20 @@ public class WorkmanagerPlugin: WorkmanagerPluginBase, FlutterPlugin, Workmanage completion(.success(WorkInfoStore.workInfoData(forUniqueName: uniqueName))) } + func notifyBackgroundChannelInitialized(completion: @escaping (Result) -> Void) { + // Dart-side readiness signal introduced with the re-inverted handshake + // (see the pigeon definition): the background isolate calls this from + // Workmanager().executeTask after registering its task handlers. + // + // Apple workers still gate task execution on the reply of the legacy + // native-initiated `backgroundChannelInitialized` call (the kick that + // lazily runs the callbackDispatcher on the main engine), so there is + // nothing to do here yet. Aligning the Apple headless-engine flow with + // the Dart-initiated handshake (mirroring the Android fix for + // #732/#738) is tracked as a follow-up. + completion(.success(())) + } + // MARK: - WorkmanagerHostApi implementation (iOS) #if os(iOS) diff --git a/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift index 29e995be..43d541e2 100644 --- a/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift @@ -1178,6 +1178,21 @@ protocol WorkmanagerHostApi { /// messenger of the engine that made this call. On platforms without /// progress support the call is a no-op. func setProgressListener(enabled: Bool, completion: @escaping (Result) -> Void) + /// Signals the native side that the background isolate's task handlers are + /// registered on this engine's messenger. + /// + /// Called from Dart by `Workmanager().executeTask`, after + /// [WorkmanagerFlutterApi]'s handlers have been set up. Native background + /// workers wait for this signal before invoking + /// [WorkmanagerFlutterApi.executeTask]; because the signal is sent from + /// Dart only after setUp completed, the follow-up `executeTask` call can + /// never race isolate startup (regression introduced by the Pigeon + /// migration, which flipped the handshake to native-initiated — see + /// #732/#738). + /// + /// Platforms and engines that never execute a Dart background task (the app + /// engine on Android, web) may ignore the call. + func notifyBackgroundChannelInitialized(completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -1435,6 +1450,35 @@ class WorkmanagerHostApiSetup { } else { setProgressListenerChannel.setMessageHandler(nil) } + /// Signals the native side that the background isolate's task handlers are + /// registered on this engine's messenger. + /// + /// Called from Dart by `Workmanager().executeTask`, after + /// [WorkmanagerFlutterApi]'s handlers have been set up. Native background + /// workers wait for this signal before invoking + /// [WorkmanagerFlutterApi.executeTask]; because the signal is sent from + /// Dart only after setUp completed, the follow-up `executeTask` call can + /// never race isolate startup (regression introduced by the Pigeon + /// migration, which flipped the handshake to native-initiated — see + /// #732/#738). + /// + /// Platforms and engines that never execute a Dart background task (the app + /// engine on Android, web) may ignore the call. + let notifyBackgroundChannelInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.notifyBackgroundChannelInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + notifyBackgroundChannelInitializedChannel.setMessageHandler { _, reply in + api.notifyBackgroundChannelInitialized { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + notifyBackgroundChannelInitializedChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart index b57ec4c6..d24671ac 100644 --- a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart +++ b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart @@ -1420,6 +1420,38 @@ class WorkmanagerHostApi { ) ; } + + /// Signals the native side that the background isolate's task handlers are + /// registered on this engine's messenger. + /// + /// Called from Dart by `Workmanager().executeTask`, after + /// [WorkmanagerFlutterApi]'s handlers have been set up. Native background + /// workers wait for this signal before invoking + /// [WorkmanagerFlutterApi.executeTask]; because the signal is sent from + /// Dart only after setUp completed, the follow-up `executeTask` call can + /// never race isolate startup (regression introduced by the Pigeon + /// migration, which flipped the handshake to native-initiated — see + /// #732/#738). + /// + /// Platforms and engines that never execute a Dart background task (the app + /// engine on Android, web) may ignore the call. + Future notifyBackgroundChannelInitialized() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.notifyBackgroundChannelInitialized$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } } abstract class WorkmanagerFlutterApi { diff --git a/workmanager_platform_interface/pigeons/workmanager_api.dart b/workmanager_platform_interface/pigeons/workmanager_api.dart index 9679d7ce..208fd879 100644 --- a/workmanager_platform_interface/pigeons/workmanager_api.dart +++ b/workmanager_platform_interface/pigeons/workmanager_api.dart @@ -526,6 +526,23 @@ abstract class WorkmanagerHostApi { /// progress support the call is a no-op. @async void setProgressListener(bool enabled); + + /// Signals the native side that the background isolate's task handlers are + /// registered on this engine's messenger. + /// + /// Called from Dart by `Workmanager().executeTask`, after + /// [WorkmanagerFlutterApi]'s handlers have been set up. Native background + /// workers wait for this signal before invoking + /// [WorkmanagerFlutterApi.executeTask]; because the signal is sent from + /// Dart only after setUp completed, the follow-up `executeTask` call can + /// never race isolate startup (regression introduced by the Pigeon + /// migration, which flipped the handshake to native-initiated — see + /// #732/#738). + /// + /// Platforms and engines that never execute a Dart background task (the app + /// engine on Android, web) may ignore the call. + @async + void notifyBackgroundChannelInitialized(); } // Flutter API (Native calls Flutter)