From 6782ebc585f0c190883f9188747c62ddd4ab3530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 9 Sep 2026 18:31:54 +0700 Subject: [PATCH 1/9] fix(dart-worker): cooperative cancellation via isTaskCancelled (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a task via NativeWorkManager.cancel()/cancelAll(), or the OS reclaiming background time, does not interrupt a running DartWorker callback — Dart has no API to preemptively abort a Future that is already executing. A running callback had no way to even find out it had been cancelled. Adds NativeWorkManager.isTaskCancelled(taskId): a callback doing long-running work polls it cooperatively between chunks and returns early once it turns true. Wired end-to-end on both platforms via a new DartTaskCancellationRegistry (Kotlin + Swift): - Android: FlutterEngineManager.executeDartCallback marks the registry the moment CoroutineWorker cancellation is observed. - iOS foreground/simulator: every activeTasks[taskId]?.cancel() call site (cancel/cancelAll/cancelByTag, notification-driven cancel) now also marks the registry. - iOS true-background: BGTaskSchedulerManager's expirationHandler now marks the registry AND actually cancels the running Task — previously it only called activeWorker?.stop() (a no-op for DartCallbackWorker), so an expired task's work kept running past the task's own completion. Along the way, fixed a real bug found while tracing the Android cancellation path: external CancellationException in executeDartCallback bypassed dispose()/scheduleDisposalCheck() entirely (caught only by the outer rethrow-and-propagate catch), so a cancelled DartWorker task either leaked the ~50MB headless engine forever, or — if another task's idle timer was already armed — could dispose the engine while the orphaned callback was still running. Tests: - test/unit/issue_66_dart_worker_cancellation_test.dart (Dart consumer test, mocked channel — round-trip + empty-taskId + no-handler guards) - android/.../engine/DartTaskCancellationRegistryTest.kt (registry incl. concurrent access from many threads) - example/integration_test/device_integration_test.dart: issue_66 cancel-while-running test (not yet run on a physical device — no device/emulator was available in this session) - channel_method_parity_test.dart exemption added for isTaskCancelled Verified: flutter analyze clean, full Dart test suite green (1990 tests), Android Gradle unit tests green, iOS Swift changes compile via a real `flutter build ios --simulator` (a standalone `xcodebuild`/`swift build` of the SPM package fails on unrelated pre-existing issues: BGTask unavailable on macOS, Flutter module unresolved outside a real Flutter build context — reproduced identically on unmodified main). Claude-Session: https://claude.ai/code/session_011aaZsf2gDsriP8hfMXbgG8 --- CHANGELOG.md | 30 ++++++ README.md | 18 ++++ .../engine/DartTaskCancellationRegistry.kt | 36 +++++++ .../engine/FlutterEngineManager.kt | 98 ++++++++++++++++--- .../workers/DartCallbackWorker.kt | 9 +- .../DartTaskCancellationRegistryTest.kt | 97 ++++++++++++++++++ .../device_integration_test.dart | 90 +++++++++++++++++ example/ios/Podfile.lock | 4 +- .../NativeWorkmanagerPlugin+Cancel.swift | 6 ++ .../NativeWorkmanagerPlugin+Execution.swift | 6 ++ ...tiveWorkmanagerPlugin+StreamHandlers.swift | 1 + .../NativeWorkmanagerPlugin.swift | 35 ++++--- .../engine/DartTaskCancellationRegistry.swift | 47 +++++++++ .../engine/FlutterEngineManager.swift | 5 + .../scheduling/BGTaskSchedulerManager.swift | 23 ++++- lib/src/native_work_manager.dart | 50 ++++++++++ test/unit/channel_method_parity_test.dart | 3 + ...ssue_66_dart_worker_cancellation_test.dart | 75 ++++++++++++++ 18 files changed, 602 insertions(+), 31 deletions(-) create mode 100644 android/src/main/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistry.kt create mode 100644 android/src/test/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistryTest.kt create mode 100644 ios/native_workmanager/Sources/native_workmanager/engine/DartTaskCancellationRegistry.swift create mode 100644 test/unit/issue_66_dart_worker_cancellation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bff3fc7..fa4e968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`NativeWorkManager.isTaskCancelled(taskId)`** — answers + [#66](https://github.com/brewkits/native_workmanager/discussions/66): + cancelling a task (via `cancel`/`cancelAll`, or the OS reclaiming + background time) does not interrupt a running `DartWorker` callback, + because Dart has no API to preemptively abort a `Future` that is already + executing. A callback doing long-running work can now poll this + cooperatively between chunks of work and return early once it turns + `true`. Wired on both platforms: Android (`CoroutineWorker` + cancellation), iOS foreground/simulator (main-isolate `activeTasks` + cancel), and iOS true-background BGTask expiration. See + `DartTaskCancellationRegistry` (Kotlin and Swift) and the `issue_66_*` + entries in `device_integration_test.dart`. + +### Fixed + +- **Android: cancelling a `DartWorker` task while its callback was running + could leak the headless Flutter engine (~50 MB) or dispose it while an + orphaned callback was still executing.** `FlutterEngineManager + .executeDartCallback` rethrew external `CancellationException` before its + own dispose/idle-timer logic ever ran. Found investigating #66. +- **iOS: `BGTaskSchedulerManager` never actually cancelled the running + `Task` on BGTask expiration** — only `activeWorker.stop()` was called (a + no-op for `DartCallbackWorker`), so the work backing an expired task kept + running in the background past the task's own completion. Found + investigating #66. + ## [1.6.1] - 2026-09-07 **Fixes a regression in 1.6.0.** If you are on 1.6.0 and use any worker whose result contains a diff --git a/README.md b/README.md index 60c42c1..c851e27 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,24 @@ Future syncHealthData(Map? input) async { } ``` +Cancelling a task (`cancel`/`cancelAll`, or the OS reclaiming background time) does **not** interrupt a running `DartWorker` callback — Dart has no API to preemptively abort a `Future` that is already executing. A callback doing long-running work should poll `NativeWorkManager.isTaskCancelled(taskId)` cooperatively between chunks of work and return promptly once it turns `true`: + +```dart +@pragma('vm:entry-point') +Future longSync(Map? input) async { + final taskId = input?['__taskId'] as String?; + for (var i = 1; i <= 100; i++) { + if (taskId != null && await NativeWorkManager.isTaskCancelled(taskId)) { + return false; // bail out — do not keep working + } + await processChunk(i); + } + return true; +} +``` + +An `await longRunningOperation()` with no cancellation checks of its own keeps running regardless — break such work into chunks so there's a point to check from. + > **Android killed-app support** — When Android kills your app and WorkManager later fires a `DartWorker`, the process restarts without Flutter. Since **v1.3.0 this is zero-config**: the plugin's `androidx.startup` initializer restores the `callbackHandle` and installs its `WorkerFactory` automatically before any task fires — no custom `Application` class required. Apps that ship their own `Configuration.Provider` can opt out — see **[Android Setup Guide](doc/ANDROID_SETUP.md)**. ### Code generation for DartWorker diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistry.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistry.kt new file mode 100644 index 0000000..cda2781 --- /dev/null +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistry.kt @@ -0,0 +1,36 @@ +package dev.brewkits.native_workmanager.engine + +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks which DartWorker task IDs have been cancelled, so a Dart callback + * running in the headless [FlutterEngineManager] engine can ask + * `NativeWorkManager.isTaskCancelled(taskId)` (issue #66) and get a real + * answer instead of always `false`. + * + * Marked from [FlutterEngineManager.executeDartCallback] the moment the + * coroutine backing `doWork()` observes external cancellation (WorkManager + * stopping the worker). This is **cooperative only**: marking a taskId here + * does not interrupt whatever the Dart isolate is currently `await`-ing — + * it only lets a polling callback see the request and return early. + */ +object DartTaskCancellationRegistry { + + private val cancelled: MutableSet = ConcurrentHashMap.newKeySet() + + /** Record that [taskId] has been cancelled/stopped. */ + fun markCancelled(taskId: String) { + cancelled.add(taskId) + } + + /** Whether [taskId] has been marked cancelled. */ + fun isCancelled(taskId: String): Boolean = cancelled.contains(taskId) + + /** + * Remove [taskId]'s entry once its execution has finished, successfully + * or not — otherwise every cancelled taskId leaks in this set forever. + */ + fun clear(taskId: String) { + cancelled.remove(taskId) + } +} diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt index 77594be..7a4dc48 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt @@ -88,9 +88,38 @@ object FlutterEngineManager { callbackHandle: Long, input: String?, timeoutMs: Long = 5 * 60 * 1000L, - disposeImmediately: Boolean = false + disposeImmediately: Boolean = false, + // Issue #66: when non-null, lets this function mark + // DartTaskCancellationRegistry on external cancellation so + // NativeWorkManager.isTaskCancelled(taskId) is observable from inside + // the still-running Dart callback. Always cleared before returning. + taskId: String? = null ): Boolean = withContext(Dispatchers.Main) { try { + executeDartCallbackInternal( + context, callbackHandle, input, timeoutMs, disposeImmediately, taskId + ) + } finally { + // Issue #66: always drop this taskId's cancellation-registry entry + // once execution is done, on every exit path (success, failure, + // timeout, or cancellation) — kept in one place, outside the + // catch blocks below, so it cannot interfere with the + // `catch (e: CancellationException) { throw e }` shape that + // cancellation_rethrow_invariant_test.dart requires immediately + // after the brace. + if (taskId != null) DartTaskCancellationRegistry.clear(taskId) + } + } + + private suspend fun executeDartCallbackInternal( + context: Context, + callbackHandle: Long, + input: String?, + timeoutMs: Long, + disposeImmediately: Boolean, + taskId: String? + ): Boolean { + return try { NativeLogger.d("Executing Dart callback with handle: $callbackHandle") ensureEngineInitialized(context) @@ -119,7 +148,7 @@ object FlutterEngineManager { try { val channel = methodChannel if (channel == null) { - return@withContext false + return false } val resultDeferred = CompletableDeferred() @@ -142,6 +171,15 @@ object FlutterEngineManager { }) var timedOut = false + // Issue #66: external cancellation (WorkManager stopping this + // worker — user cancel, or the OS reclaiming background time) + // throws a plain CancellationException here too, since it's a + // child of the same coroutine job as TimeoutCancellationException's + // parent. It must be caught at this level (not just rethrown from + // the outer catch below) or the dispose/scheduleDisposalCheck logic + // beneath never runs for a cancelled task — see wasCancelled below. + var wasCancelled = false + var cancellationCause: kotlinx.coroutines.CancellationException? = null val result = try { withTimeout(timeoutMs) { resultDeferred.await() } } catch (e: TimeoutCancellationException) { @@ -150,10 +188,21 @@ object FlutterEngineManager { // isolate leaks ~50 MB RAM and burns CPU until the OS kills the process. timedOut = true false + } catch (e: kotlinx.coroutines.CancellationException) { + wasCancelled = true + cancellationCause = e + false } finally { releaseTaskCount() } + // Mark the registry as soon as we know about cancellation, before + // any dispose/return below — the Dart callback may be polling + // isTaskCancelled() right now and should see it as early as possible. + if (wasCancelled && taskId != null) { + DartTaskCancellationRegistry.markCancelled(taskId) + } + if (timedOut) { NativeLogger.e("DartWorker timed out after ${timeoutMs}ms — force-disposing engine to free hung isolate") // Only destroy the engine if this is the sole in-flight task. @@ -162,7 +211,22 @@ object FlutterEngineManager { if (activeTaskCount.get() <= 0) { try { dispose() } catch (_: Exception) {} } - return@withContext false + return false + } + + if (wasCancelled) { + // Unlike a timeout, cancellation does not necessarily mean the + // isolate is hung — a cooperative callback may return on its own + // in a moment. Don't force-dispose; just let the normal idle + // timer reclaim the engine if this was the last in-flight task. + // (The registry entry itself is cleared by executeDartCallback's + // outer finally, not here — the orphaned callback, if any, may + // still be polling isTaskCancelled() a moment longer.) + NativeLogger.d("DartWorker cancelled externally (taskId=$taskId)") + if (activeTaskCount.get() <= 0) { + scheduleDisposalCheck() + } + throw cancellationCause ?: kotlinx.coroutines.CancellationException("DartWorker cancelled") } if (disposeImmediately && activeTaskCount.get() <= 0) { @@ -179,15 +243,19 @@ object FlutterEngineManager { // invokeMethod or the argument marshalling above. releaseTaskCount() } - } catch (e: kotlinx.coroutines.CancellationException) { - // CancellationException is-a Exception, so the generic catch below - // would swallow a real cancellation (parent Job cancelled, task - // cancelled via WorkManager) and report it as a plain `false` - // failure. Rethrow so structured concurrency is preserved. - // - // The withTimeout above is unaffected: TimeoutCancellationException - // is caught locally at its call site and converted to `timedOut`, - // so the DartWorker timeout path never reaches here. + } + // CancellationException is-a Exception, so the generic catch below + // would swallow a real cancellation (parent Job cancelled, task + // cancelled via WorkManager) and report it as a plain `false` + // failure. Rethrow so structured concurrency is preserved. + // + // The withTimeout above is unaffected: TimeoutCancellationException is + // caught locally at its call site and converted to `timedOut`, and a + // plain external CancellationException is now also caught locally + // (wasCancelled) so disposal runs before this rethrows — so the + // DartWorker timeout/cancel paths never fall through to here except + // via the explicit rethrow above. + catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { NativeLogger.e("Error executing Dart callback", e) @@ -317,6 +385,12 @@ object FlutterEngineManager { ProgressReporter.reportProgressNonBlocking(taskId, progress, message) result.success(null) } + // Issue #66: cooperative cancellation poll from inside a + // running DartWorker callback. See DartTaskCancellationRegistry. + "isTaskCancelled" -> { + val taskId = call.argument("taskId") ?: "" + result.success(DartTaskCancellationRegistry.isCancelled(taskId)) + } else -> result.notImplemented() } } diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/DartCallbackWorker.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/DartCallbackWorker.kt index fdf7667..547bf35 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/DartCallbackWorker.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/DartCallbackWorker.kt @@ -115,13 +115,18 @@ class DartCallbackWorkerWrapper( Log.d(TAG, "Executing callback: $callbackId (handle: $callbackHandle, autoDispose: $autoDispose, timeoutMs: $timeoutMs)") // Execute Dart callback via FlutterEngineManager - // Pass callbackHandle (not callbackId) to enable cross-isolate execution + // Pass callbackHandle (not callbackId) to enable cross-isolate execution. + // taskId (issue #66) lets FlutterEngineManager mark + // DartTaskCancellationRegistry when this coroutine is cancelled + // externally, so NativeWorkManager.isTaskCancelled(taskId) can see it + // from inside the running Dart callback. val result = FlutterEngineManager.executeDartCallback( context = context, callbackHandle = callbackHandle, // Serializable handle input = callbackInput, timeoutMs = timeoutMs, - disposeImmediately = autoDispose // Aggressive disposal flag + disposeImmediately = autoDispose, // Aggressive disposal flag + taskId = outerTaskId ) Log.d(TAG, "Dart callback completed: $callbackId, result: $result") diff --git a/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistryTest.kt b/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistryTest.kt new file mode 100644 index 0000000..4ea62a0 --- /dev/null +++ b/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/DartTaskCancellationRegistryTest.kt @@ -0,0 +1,97 @@ +package dev.brewkits.native_workmanager.engine + +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Issue #66: https://github.com/brewkits/native_workmanager/discussions/66 + * + * `DartTaskCancellationRegistry` is the Android half of the bridge that lets + * `NativeWorkManager.isTaskCancelled(taskId)` — called from *inside* a + * running DartWorker callback — see real cancellation state. Pure Kotlin, no + * Android framework dependency, so no Robolectric needed here; the + * native→Dart wiring itself (FlutterEngineManager.executeDartCallback marking + * this registry on a real CoroutineWorker cancellation) is covered by the + * `issue_66_*` device integration test, per the CLAUDE.md rule that a field + * crossing Dart → native → Dart needs more than a unit test on one side. + */ +class DartTaskCancellationRegistryTest { + + @Test + fun `unknown taskId is not cancelled`() { + assertFalse(DartTaskCancellationRegistry.isCancelled("never-seen")) + } + + @Test + fun `markCancelled makes isCancelled true`() { + val taskId = "t-${System.nanoTime()}" + assertFalse(DartTaskCancellationRegistry.isCancelled(taskId)) + + DartTaskCancellationRegistry.markCancelled(taskId) + + assertTrue(DartTaskCancellationRegistry.isCancelled(taskId)) + } + + @Test + fun `clear removes the entry so isCancelled reverts to false`() { + val taskId = "t-${System.nanoTime()}" + DartTaskCancellationRegistry.markCancelled(taskId) + assertTrue(DartTaskCancellationRegistry.isCancelled(taskId)) + + DartTaskCancellationRegistry.clear(taskId) + + assertFalse(DartTaskCancellationRegistry.isCancelled(taskId)) + } + + @Test + fun `clear on an entry that was never marked is a no-op, not a crash`() { + val taskId = "t-${System.nanoTime()}" + DartTaskCancellationRegistry.clear(taskId) // must not throw + assertFalse(DartTaskCancellationRegistry.isCancelled(taskId)) + } + + @Test + fun `taskIds are tracked independently`() { + val a = "task-a-${System.nanoTime()}" + val b = "task-b-${System.nanoTime()}" + + DartTaskCancellationRegistry.markCancelled(a) + + assertTrue(DartTaskCancellationRegistry.isCancelled(a)) + assertFalse(DartTaskCancellationRegistry.isCancelled(b)) + } + + @Test + fun `concurrent markCancelled from many threads is observed for every taskId`() { + // FlutterEngineManager.executeDartCallback marks this registry from a + // coroutine that may run on a different thread than whatever is + // polling isTaskCancelled() via the MethodChannel handler — this must + // hold up under real concurrency, not just single-threaded calls. + val taskIds = (0 until 200).map { "concurrent-$it-${System.nanoTime()}" } + val pool = Executors.newFixedThreadPool(16) + val latch = CountDownLatch(taskIds.size) + + taskIds.forEach { taskId -> + pool.execute { + DartTaskCancellationRegistry.markCancelled(taskId) + latch.countDown() + } + } + + assertTrue("threads did not finish in time", latch.await(5, TimeUnit.SECONDS)) + pool.shutdown() + + taskIds.forEach { taskId -> + assertTrue( + "expected '$taskId' to be marked cancelled", + DartTaskCancellationRegistry.isCancelled(taskId) + ) + } + + // Cleanup so this test doesn't leak entries into other tests in the suite. + taskIds.forEach { DartTaskCancellationRegistry.clear(it) } + } +} diff --git a/example/integration_test/device_integration_test.dart b/example/integration_test/device_integration_test.dart index 02032d3..148346d 100644 --- a/example/integration_test/device_integration_test.dart +++ b/example/integration_test/device_integration_test.dart @@ -231,6 +231,32 @@ Future _ditRetryCounter(Map? input) async { return false; // fail → should retry under maxRetries, then abandon } +/// Issue #66 regression: https://github.com/brewkits/native_workmanager/discussions/66 +/// +/// Cancelling a task does not interrupt a running DartWorker callback — Dart +/// has no API to preemptively abort a `Future` already executing. This +/// callback polls `NativeWorkManager.isTaskCancelled(taskId)` between chunks +/// of "work" (a short delay) and writes how many iterations it completed to +/// [input]'s `counterFile`, so the test can prove it bailed out early instead +/// of running to completion (50 iterations × 200ms = 10s if never cancelled). +@pragma('vm:entry-point') +Future _ditCancelPoll(Map? input) async { + final taskId = input?['__taskId'] as String?; + final counterFile = input?['counterFile'] as String?; + for (var i = 1; i <= 50; i++) { + if (counterFile != null) { + File(counterFile).writeAsStringSync('$i'); + } + if (taskId != null && await NativeWorkManager.isTaskCancelled(taskId)) { + print('[DartWorker] dit_cancel_poll: observed cancellation at iteration $i'); + return false; + } + await Future.delayed(const Duration(milliseconds: 200)); + } + print('[DartWorker] dit_cancel_poll: completed all iterations uncancelled'); + return true; +} + @pragma('vm:entry-point') Future _workflowFinalizer(Map? input) async { print('[DartWorker] _workflowFinalizer starting...'); @@ -272,6 +298,7 @@ void main() { 'chain_c': _chainC, 'dit_progress': _ditProgress, 'dit_retry_counter': _ditRetryCounter, + 'dit_cancel_poll': _ditCancelPoll, 'workflow_finalizer': _workflowFinalizer, }, ); @@ -1532,6 +1559,69 @@ void main() { // Must not throw. await NativeWorkManager.cancelAll(); }); + + testWidgets( + 'issue_66: cancelling a running DartWorker task is observable via isTaskCancelled', + (tester) async { + // https://github.com/brewkits/native_workmanager/discussions/66 — + // cancel() does not interrupt a running Dart callback (Dart has no + // preemptive Future cancellation). dit_cancel_poll runs up to 50 + // iterations × 200ms (10s total if never cancelled), writing its + // iteration count to counterFile and polling isTaskCancelled() + // between iterations. Cancelling ~600ms in must make it stop well + // short of 50 — proving the callback actually observed the + // cancellation and returned, not merely that cancel() didn't crash. + final id = _id('issue_66_cancel_poll'); + final counterFile = File('${tmpDir.path}/issue_66_counter.txt'); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: DartWorker( + callbackId: 'dit_cancel_poll', + input: {'counterFile': counterFile.path}, + ), + ); + + // Let it run a few iterations, then cancel while it is still "in flight". + await Future.delayed(const Duration(milliseconds: 600)); + await NativeWorkManager.cancel(taskId: id); + + // Give the callback time to notice on its next poll and return — + // well short of the 10s it would take to run all 50 iterations. + await Future.delayed(const Duration(seconds: 2)); + + expect( + counterFile.existsSync(), + isTrue, + reason: 'issue_66: the callback must have started and written at ' + 'least one iteration before being cancelled', + ); + final iterationsAtCancel = int.parse(counterFile.readAsStringSync().trim()); + expect( + iterationsAtCancel, + lessThan(20), + reason: 'issue_66: cancelling ~600ms in (≈3 iterations of 200ms) ' + 'must stop the callback well short of all 50 iterations — a ' + 'count this high means isTaskCancelled() never observed the ' + 'cancellation and the callback ran to completion regardless', + ); + + // The counter must not keep climbing after cancellation was observed — + // confirms the callback actually returned instead of merely reading + // isTaskCancelled() once and continuing anyway. + final iterationsAfterWait = int.parse(counterFile.readAsStringSync().trim()); + await Future.delayed(const Duration(seconds: 2)); + final iterationsStillAfterWait = + int.parse(counterFile.readAsStringSync().trim()); + expect( + iterationsStillAfterWait, + equals(iterationsAfterWait), + reason: 'issue_66: iteration count must not still be climbing ' + '2s later — the callback should have returned, not kept working', + ); + }, + ); }); // ════════════════════════════════════════════════════════════ diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b44635c..1aeb0fc 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,7 +2,7 @@ PODS: - Flutter (1.0.0) - integration_test (0.0.1): - Flutter - - native_workmanager (1.4.5): + - native_workmanager (1.6.1): - Flutter - shared_preferences_foundation (0.0.1): - Flutter @@ -32,7 +32,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - native_workmanager: f277cfbe7d16ef5d5cf2bc3be3744e3778fc03ae + native_workmanager: e6bff0371048ebae3ccef516148d796034dddc1d shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Cancel.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Cancel.swift index 6ebb168..a4426a6 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Cancel.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Cancel.swift @@ -10,6 +10,10 @@ extension NativeWorkmanagerPlugin { func handleCancelAll(result: @escaping FlutterResult) { stateQueue.sync(flags: .barrier) { + // Issue #66: mark every in-flight taskId cancelled BEFORE cancelling + // the Swift Tasks, so a DartWorker callback polling isTaskCancelled() + // sees it even though .cancel() itself does not reach the Dart isolate. + activeTasks.keys.forEach { DartTaskCancellationRegistry.shared.markCancelled($0) } activeTasks.values.forEach { $0.cancel() } activeTasks.removeAll() taskStates.removeAll() @@ -46,6 +50,7 @@ extension NativeWorkmanagerPlugin { stateQueue.sync(flags: .barrier) { taskIdsToCancel = taskTags.compactMap { $0.value == tag ? $0.key : nil } for taskId in taskIdsToCancel { + DartTaskCancellationRegistry.shared.markCancelled(taskId) // issue #66 activeTasks[taskId]?.cancel() activeTasks.removeValue(forKey: taskId) taskStates[taskId] = .cancelled @@ -65,6 +70,7 @@ extension NativeWorkmanagerPlugin { BackgroundSessionManager.shared.cancel(taskId: taskId) taskStore?.updateStatus(taskId: taskId, status: "cancelled") BGTaskSchedulerManager.shared.cancelTask(taskId: taskId) + DartTaskCancellationRegistry.shared.markCancelled(taskId) // issue #66 stateQueue.async(flags: .barrier) { self.activeTasks[taskId]?.cancel() self.activeTasks.removeValue(forKey: taskId) diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Execution.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Execution.swift index d04f06f..647c3b8 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Execution.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+Execution.swift @@ -578,6 +578,12 @@ extension NativeWorkmanagerPlugin { workerConfig: [String: Any], taskId: String ) async -> WorkerResult { + // Issue #66: whichever branch below runs, always drop this taskId's + // cancellation-registry entry once execution is done — otherwise a + // cancelled taskId (or, worse, a reused one on a later run) leaks or + // misreports "cancelled" forever. + defer { DartTaskCancellationRegistry.shared.clear(taskId) } + guard let callbackId = workerConfig["callbackId"] as? String else { return WorkerResult.failure(message: "DartCallbackWorker: missing callbackId in config") } diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+StreamHandlers.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+StreamHandlers.swift index 9815ffa..0d65742 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+StreamHandlers.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+StreamHandlers.swift @@ -109,6 +109,7 @@ extension NativeWorkmanagerPlugin: UNUserNotificationCenterDelegate { if #available(iOS 13.0, *) { cleanupTempFiles(forTaskId: taskId) } + DartTaskCancellationRegistry.shared.markCancelled(taskId) // issue #66 stateQueue.async(flags: .barrier) { self.activeTasks[taskId]?.cancel() self.activeTasks.removeValue(forKey: taskId) diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift index c7fa601..0ebd8f3 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift @@ -118,19 +118,26 @@ public class NativeWorkmanagerPlugin: NSObject, FlutterPlugin { instance.dartWorkerChannel = FlutterMethodChannel( name: "dev.brewkits/dart_worker_channel", binaryMessenger: messenger) instance.dartWorkerChannel?.setMethodCallHandler { (call, result) in - guard call.method == "reportProgress" else { + let args = call.arguments as? [String: Any] + switch call.method { + case "reportProgress": + let taskId = args?["taskId"] as? String ?? "" + let progress = args?["progress"] as? Int ?? 0 + let message = args?["message"] as? String + // Route through ProgressReporter (same as the FlutterEngineManager + // background path): forwards to the progress EventChannel via onProgress, + // records lastEmittedUpdates, and persists last_progress_json to SQLite. + ProgressReporter.shared.report(taskId: taskId, progress: progress, message: message) + result(nil) + // Issue #66: cooperative cancellation poll from a foreground + // DartWorker callback (running in this main isolate, not the + // headless FlutterEngineManager engine — see DartTaskCancellationRegistry). + case "isTaskCancelled": + let taskId = args?["taskId"] as? String ?? "" + result(DartTaskCancellationRegistry.shared.isCancelled(taskId)) + default: result(FlutterMethodNotImplemented) - return } - let args = call.arguments as? [String: Any] - let taskId = args?["taskId"] as? String ?? "" - let progress = args?["progress"] as? Int ?? 0 - let message = args?["message"] as? String - // Route through ProgressReporter (same as the FlutterEngineManager - // background path): forwards to the progress EventChannel via onProgress, - // records lastEmittedUpdates, and persists last_progress_json to SQLite. - ProgressReporter.shared.report(taskId: taskId, progress: progress, message: message) - result(nil) } KMPBridge.shared.initialize() @@ -436,6 +443,12 @@ public class NativeWorkmanagerPlugin: NSObject, FlutterPlugin { result(FlutterError(code: "INVALID_ARGS", message: "taskId required", details: nil)) return } + // Issue #66: activeTasks[taskId]?.cancel() below only unblocks whatever + // Swift Task is awaiting — it does not reach the Dart isolate, so a + // running DartWorker callback would otherwise never know it was + // cancelled. Mark it here so NativeWorkManager.isTaskCancelled(taskId) + // (polled cooperatively from inside the callback) can see it. + DartTaskCancellationRegistry.shared.markCancelled(taskId) stateQueue.async(flags: .barrier) { self.activeTasks[taskId]?.cancel() self.activeTasks.removeValue(forKey: taskId) diff --git a/ios/native_workmanager/Sources/native_workmanager/engine/DartTaskCancellationRegistry.swift b/ios/native_workmanager/Sources/native_workmanager/engine/DartTaskCancellationRegistry.swift new file mode 100644 index 0000000..e91fed9 --- /dev/null +++ b/ios/native_workmanager/Sources/native_workmanager/engine/DartTaskCancellationRegistry.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Tracks which DartWorker task IDs have been cancelled, so a Dart callback +/// running in either Flutter engine (the main isolate, or the headless +/// engine spun up by `FlutterEngineManager`) can ask +/// `NativeWorkManager.isTaskCancelled(taskId)` (issue #66) and get a real +/// answer instead of always `false`. +/// +/// Marked from every place that cancels a running task — +/// `NativeWorkmanagerPlugin`'s `cancel`/`cancelAll`/`cancelByTag` handlers, +/// notification-driven cancel, and `BGTaskSchedulerManager`'s expiration +/// handler. Read from the `dev.brewkits/dart_worker_channel` handlers in +/// both `NativeWorkmanagerPlugin` (main isolate) and `FlutterEngineManager` +/// (headless isolate). +/// +/// This is **cooperative only**: marking a taskId here does not interrupt +/// whatever the Dart isolate is currently `await`-ing — it only lets a +/// polling callback see the request and return early. +final class DartTaskCancellationRegistry { + static let shared = DartTaskCancellationRegistry() + private init() {} + + private let lock = NSLock() + private var cancelled: Set = [] + + /// Record that `taskId` has been cancelled/stopped. + func markCancelled(_ taskId: String) { + lock.lock() + defer { lock.unlock() } + cancelled.insert(taskId) + } + + /// Whether `taskId` has been marked cancelled. + func isCancelled(_ taskId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return cancelled.contains(taskId) + } + + /// Remove `taskId`'s entry once its execution has finished, successfully + /// or not — otherwise every cancelled taskId leaks in this set forever. + func clear(_ taskId: String) { + lock.lock() + defer { lock.unlock() } + cancelled.remove(taskId) + } +} diff --git a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift index 81a2c19..420f56d 100644 --- a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift +++ b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift @@ -362,6 +362,11 @@ class FlutterEngineManager { let message = args?["message"] as? String ProgressReporter.shared.report(taskId: taskId, progress: progress, message: message) result(nil) + } else if call.method == "isTaskCancelled" { + // Issue #66: cooperative cancellation poll from inside a + // running DartWorker callback. See DartTaskCancellationRegistry. + let taskId = (call.arguments as? [String: Any])?["taskId"] as? String ?? "" + result(DartTaskCancellationRegistry.shared.isCancelled(taskId)) } else { result(FlutterMethodNotImplemented) } diff --git a/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift b/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift index 61a749f..abc3175 100644 --- a/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift +++ b/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift @@ -283,21 +283,31 @@ class BGTaskSchedulerManager { return } + // Declared before expirationHandler so the handler can cancel it — + // issue #66: previously expiration only called activeWorker?.stop() + // (a no-op for DartCallbackWorker) and never cancelled the Task + // driving runExecutor, so a Dart callback's cooperative + // isTaskCancelled() poll had nothing to observe on OS-triggered + // expiration, only on an explicit NativeWorkManager.cancel() call. + var runningTask: Task? + task.expirationHandler = { [weak self] in NativeLogger.d("BGTaskSchedulerManager: Task expired") + DartTaskCancellationRegistry.shared.markCancelled(taskInfo.taskId) + runningTask?.cancel() self?.activeWorker?.stop() self?.onExpiration?() self?.onTaskComplete?(taskInfo.taskId, false, "Task expired") completionGuard.completeOnce(task: task, success: false) } - let runningTask = Task(priority: .background) { [weak self] in + runningTask = Task(priority: .background) { [weak self] in guard let self = self else { return } let success = await self.runExecutor(taskInfo: taskInfo) completionGuard.completeOnce(task: task, success: success) self.activeWorker = nil } - onTaskRunning?(taskInfo.taskId, runningTask) + onTaskRunning?(taskInfo.taskId, runningTask!) } /// Handle BGAppRefreshTask execution. @@ -313,21 +323,26 @@ class BGTaskSchedulerManager { return } + // See handleBackgroundTask's comment — same issue #66 fix. + var runningTask: Task? + task.expirationHandler = { [weak self] in NativeLogger.d("BGTaskSchedulerManager: Refresh task expired") + DartTaskCancellationRegistry.shared.markCancelled(taskInfo.taskId) + runningTask?.cancel() self?.activeWorker?.stop() self?.onExpiration?() self?.onTaskComplete?(taskInfo.taskId, false, "Refresh expired") completionGuard.completeOnce(task: task, success: false) } - let runningTask = Task(priority: .background) { [weak self] in + runningTask = Task(priority: .background) { [weak self] in guard let self = self else { return } let success = await self.runExecutor(taskInfo: taskInfo) completionGuard.completeOnce(task: task, success: success) self.activeWorker = nil } - onTaskRunning?(taskInfo.taskId, runningTask) + onTaskRunning?(taskInfo.taskId, runningTask!) } /// Shared execution path for both BGProcessingTask and BGAppRefreshTask. diff --git a/lib/src/native_work_manager.dart b/lib/src/native_work_manager.dart index 33150ec..5541471 100644 --- a/lib/src/native_work_manager.dart +++ b/lib/src/native_work_manager.dart @@ -1072,6 +1072,56 @@ class NativeWorkManager { }); } + /// Check whether [taskId] has been cancelled or stopped by the OS. + /// + /// Issue #66: cancelling a task (via [cancel]/[cancelAll], or the OS + /// reclaiming background time) does **not** interrupt a running + /// `DartWorker` callback — Dart has no API to preemptively abort a + /// `Future` that is already executing. A callback doing long-running work + /// must instead poll this **cooperatively** and return promptly once it + /// turns `true`: + /// + /// ```dart + /// 'longSync': (input) async { + /// final taskId = input?['__taskId'] as String?; + /// for (var i = 1; i <= 100; i++) { + /// if (taskId != null && await NativeWorkManager.isTaskCancelled(taskId)) { + /// return false; // bail out — do not keep working + /// } + /// await processChunk(i); + /// } + /// return true; + /// }, + /// ``` + /// + /// An `await longRunningOperation()` with no cancellation checks of its own + /// will keep running regardless of this API — break such work into chunks + /// (or pass cancellation into the operation itself) so there is a point to + /// check from. + /// + /// **Thread safety:** Safe to call from any isolate that has access to the + /// `dev.brewkits/dart_worker_channel` MethodChannel (i.e., the background + /// isolate spawned by DartWorker execution, or the main isolate when the + /// callback runs in the foreground). + /// + /// Returns `false` (never throws) if [taskId] is empty or the platform + /// channel call fails — a transient failure to check must not be mistaken + /// for "not cancelled forever," but it also must not crash the caller. + static Future isTaskCancelled(String taskId) async { + if (taskId.isEmpty) return false; + const channel = MethodChannel('dev.brewkits/dart_worker_channel'); + try { + final result = await channel + .invokeMethod('isTaskCancelled', { + 'taskId': taskId, + }); + return result ?? false; + } catch (e) { + developer.log('[NativeWorkManager] isTaskCancelled($taskId) failed: $e'); + return false; + } + } + /// Get the current status of a task. /// /// Query the execution state of a specific task. Useful for showing diff --git a/test/unit/channel_method_parity_test.dart b/test/unit/channel_method_parity_test.dart index 0799a4d..ca1a72e 100644 --- a/test/unit/channel_method_parity_test.dart +++ b/test/unit/channel_method_parity_test.dart @@ -36,6 +36,9 @@ void main() { // plugin's own onMethodCall. 'dartReady': 'dart_worker_channel — FlutterEngineManager handles it', 'reportProgress': 'dart_worker_channel — FlutterEngineManager handles it', + 'isTaskCancelled': 'dart_worker_channel — FlutterEngineManager handles it ' + '(iOS also answers it from the main-isolate dartWorkerChannel handler ' + 'in NativeWorkmanagerPlugin.swift, issue #66)', }; /// Resolves a repo path whether the test runs from the repo root or elsewhere. diff --git a/test/unit/issue_66_dart_worker_cancellation_test.dart b/test/unit/issue_66_dart_worker_cancellation_test.dart new file mode 100644 index 0000000..f488eb2 --- /dev/null +++ b/test/unit/issue_66_dart_worker_cancellation_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; + +/// Issue #66: https://github.com/brewkits/native_workmanager/discussions/66 +/// +/// Cancelling a task does not interrupt a running `DartWorker` callback — +/// Dart has no API to preemptively abort a `Future` that is already +/// executing. `NativeWorkManager.isTaskCancelled(taskId)` gives a callback a +/// way to poll cooperatively and bail out. +/// +/// This is a Dart-side consumer test only (per the CLAUDE.md issue #30 rule, +/// serialization/round-trip tests alone are not sufficient for a field that +/// crosses Dart → native → Dart — those are covered on the native side by +/// unit tests + `issue_66_*` entries in device_integration_test.dart). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('dev.brewkits/dart_worker_channel'); + final calls = []; + + setUp(() { + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'isTaskCancelled') { + final taskId = (call.arguments as Map)['taskId'] as String; + return taskId == 'cancelled-task'; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('isTaskCancelled', () { + test('returns false without a platform call when taskId is empty', () async { + final result = await NativeWorkManager.isTaskCancelled(''); + expect(result, isFalse); + expect(calls, isEmpty); + }); + + test('forwards taskId and returns the native reply (false case)', () async { + final result = await NativeWorkManager.isTaskCancelled('running-task'); + expect(result, isFalse); + expect(calls, hasLength(1)); + expect(calls.single.method, 'isTaskCancelled'); + expect(calls.single.arguments, {'taskId': 'running-task'}); + }); + + test('forwards taskId and returns the native reply (true case)', () async { + final result = await NativeWorkManager.isTaskCancelled('cancelled-task'); + expect(result, isTrue); + expect(calls, hasLength(1)); + expect(calls.single.arguments, {'taskId': 'cancelled-task'}); + }); + + test('returns false, not an exception, if the platform channel has no handler', () async { + // Simulates a platform that has not registered a handler for this + // method on this channel yet — MissingPluginException must not crash + // a DartWorker callback that is only trying to check cancellation. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + + await expectLater( + NativeWorkManager.isTaskCancelled('any-task'), + completion(isFalse), + ); + }); + }); +} From 33cffa0f7726c4039444c72c2e3c7c23af4d0255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 9 Sep 2026 22:46:20 +0700 Subject: [PATCH 2/9] fix(ios): background-session HTTP download/upload cancel never reached the transfer (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found auditing for bugs similar to #66/#67 — same "cancel signal doesn't reach the actual operation" shape, this time on native (non- Dart) workers. HttpDownloadWorker.downloadWithBackgroundSession and HttpUploadWorker.uploadWithBackgroundSession (both gated by useBackgroundSession: true, iOS-only) registered their URLSessionDownloadTask/URLSessionUploadTask with BackgroundSessionManager under a throwaway random id ("download-" / "upload-") instead of the real plugin task id. NativeWorkManager.cancel()/ cancelAll()/cancelByTag() all call BackgroundSessionManager.cancel(taskId: ) to reach these — which always missed, since the transfer was registered under a different, never-exposed id. The download/ upload just kept running regardless of cancel(). Fix: both functions now accept the real task id (extracted from __taskId in the input JSON, same pattern HttpDownloadWorker.doWork already used for progress reporting) and register with BackgroundSessionManager under that id. Falls back to a random id only when none is available. No change needed to BackgroundSessionManager itself — the existing cancel(taskId:) calls already wired into every cancel path now find the right task. Verified red-then-green on a real iOS simulator: the new device test (issue_69) reproduces the bug on the pre-fix code — cancelling 1s into a request to a 6s-delayed endpoint still writes the destination file — then passes with the fix. Also reran the full Cancellation and All Workers groups on-device: no regressions. Android has no equivalent code path (WorkManager itself is the background-survival mechanism there, no separate background-session concept), so no Android-side fix is needed. Claude-Session: https://claude.ai/code/session_011aaZsf2gDsriP8hfMXbgG8 --- CHANGELOG.md | 10 ++++ .../device_integration_test.dart | 54 +++++++++++++++++++ .../workers/HttpDownloadWorker.swift | 18 +++++-- .../workers/HttpUploadWorker.swift | 21 ++++++-- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4e968..4830b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 no-op for `DartCallbackWorker`), so the work backing an expired task kept running in the background past the task's own completion. Found investigating #66. +- **iOS: cancelling a `useBackgroundSession: true` `HttpDownloadWorker` or + `HttpUploadWorker` never actually stopped the transfer** + ([#69](https://github.com/brewkits/native_workmanager/issues/69)). Both + registered their background `URLSessionTask` with + `BackgroundSessionManager` under a throwaway random id instead of the + real task id, so `cancel()`/`cancelAll()`/`cancelByTag()` — which look + the task up by the real id — always missed. The download/upload kept + running in the background regardless. Found auditing for bugs similar + to #66; verified red-then-green with a device test that reproduces the + bug on the pre-fix code before confirming the fix. ## [1.6.1] - 2026-09-07 diff --git a/example/integration_test/device_integration_test.dart b/example/integration_test/device_integration_test.dart index 148346d..1f6ed63 100644 --- a/example/integration_test/device_integration_test.dart +++ b/example/integration_test/device_integration_test.dart @@ -1622,6 +1622,60 @@ void main() { ); }, ); + + testWidgets( + 'issue_69: cancelling a background-session download actually aborts the transfer (iOS)', + (tester) async { + // https://github.com/brewkits/native_workmanager/issues/69 — found + // auditing for bugs similar to #66. HttpDownloadWorker's + // useBackgroundSession path used to register its URLSessionDownloadTask + // with BackgroundSessionManager under a throwaway random id, so + // NativeWorkManager.cancel(taskId) — which looks the task up by the + // REAL taskId — could never find it. The transfer just kept running. + // + // httpbin.org/delay/6 doesn't send any response for 6s. Cancel at 1s + // (well before the server ever responds) and check again well past + // the 6s mark: if cancel() actually reached the URLSessionDownloadTask, + // the request is aborted and destinationURL is never written. If the + // old bug were still present, the server eventually answers, the + // (empty) body streams down, and the file appears. + if (!Platform.isIOS) { + markTestSkipped( + 'useBackgroundSession is iOS-only on ${Platform.operatingSystem}', + ); + return; + } + + final id = _id('issue_69_bg_download_cancel'); + final savePath = '${tmpDir.path}/issue_69_bg_download.bin'; + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: HttpDownloadWorker( + url: 'https://httpbin.org/delay/6', + savePath: savePath, + useBackgroundSession: true, + ), + constraints: const Constraints(requiresNetwork: true), + ); + + await Future.delayed(const Duration(seconds: 1)); + await NativeWorkManager.cancel(taskId: id); + + // Past the 6s server-side delay, so if the transfer were still alive + // it would have completed and written the file by now. + await Future.delayed(const Duration(seconds: 8)); + + expect( + File(savePath).existsSync(), + isFalse, + reason: 'issue_69: a cancelled background-session download must ' + 'not still write its destination file — a file here means ' + 'cancel() never reached the actual URLSessionDownloadTask', + ); + }, + ); }); // ════════════════════════════════════════════════════════════ diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift index 5b6c39c..703f2b0 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift @@ -278,7 +278,8 @@ class HttpDownloadWorker: IosWorker { tempURL: tempURL, config: config, headers: config.headers, - existingBytes: existingBytes + existingBytes: existingBytes, + taskId: taskIdForProgress ) } if #available(iOS 15.0, *), @@ -725,10 +726,21 @@ class HttpDownloadWorker: IosWorker { tempURL: URL, config: Config, headers: [String: String]?, - existingBytes: Int64 + existingBytes: Int64, + // The plugin's real task ID (from __taskId in the input JSON), not to be + // confused with the throwaway ID this used to generate. Cancelling this + // task (NativeWorkManager.cancel/cancelAll/cancelByTag) calls + // BackgroundSessionManager.shared.cancel(taskId: ) — that + // only reaches this download if it's registered under the SAME id. + // Previously this generated its own random "download-" id, so a + // background-session download could never actually be cancelled: the + // cancel signal looked up a taskId this download was never registered + // under. Falls back to a random id only if no real taskId is available + // (keeps this callable without one, e.g. from isolated tests). + taskId: String? ) async -> WorkerResult { return await withCheckedContinuation { continuation in - let taskId = "download-\(UUID().uuidString)" + let taskId = taskId ?? "download-\(UUID().uuidString)" BackgroundSessionManager.shared.download( url: url, diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift index f968bfd..1c79231 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift @@ -156,6 +156,12 @@ class HttpUploadWorker: IosWorker { return .failure(message: "Invalid input encoding") } + // Extract __taskId injected by the plugin, needed so a background-session + // upload registers under the same id NativeWorkManager.cancel() looks up + // (same pattern as HttpDownloadWorker's taskIdForProgress). + let taskIdForCancel: String? = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + .flatMap { $0["__taskId"] as? String } + let config: Config do { config = try JSONDecoder().decode(Config.self, from: data) @@ -247,7 +253,8 @@ class HttpUploadWorker: IosWorker { url: url, config: config, validatedFiles: validatedFiles, - totalSize: totalSize + totalSize: totalSize, + taskId: taskIdForCancel ) } @@ -507,7 +514,15 @@ class HttpUploadWorker: IosWorker { url: URL, config: Config, validatedFiles: [(url: URL, fileName: String, mimeType: String)], - totalSize: Int64 + totalSize: Int64, + // Same fix as HttpDownloadWorker.downloadWithBackgroundSession: this used + // to register the background upload under a random "upload-" id, + // so NativeWorkManager.cancel(taskId) — which calls + // BackgroundSessionManager.shared.cancel(taskId: ) — could + // never find it. A cancelled background-session upload just kept + // uploading. Falls back to a random id only if no real taskId is + // available. + taskId: String? ) async -> WorkerResult { NativeLogger.d("HttpUploadWorker: Using background URLSession for upload") @@ -531,7 +546,7 @@ class HttpUploadWorker: IosWorker { // Execute upload using BackgroundSessionManager return await withCheckedContinuation { continuation in - let taskId = "upload-\(UUID().uuidString)" + let taskId = taskId ?? "upload-\(UUID().uuidString)" BackgroundSessionManager.shared.upload( to: url, From 241e41c4a78e376b8af00914588d64ba1b4ca91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 9 Sep 2026 23:14:18 +0700 Subject: [PATCH 3/9] =?UTF-8?q?fix(android):=20isTaskCancelled=20cleared?= =?UTF-8?q?=20the=20moment=20it=20was=20marked=20=E2=80=94=20feature=20was?= =?UTF-8?q?=20a=20no-op=20on=20real=20hardware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device-verified on a Pixel 6 Pro (no emulator/device was available in the prior commits on this branch — this is the actual on-device verification the PR called out as still missing). The registry was cleared from a `finally` around executeDartCallback's own coroutine, which runs the instant THIS coroutine unwinds after observing external cancellation. But the whole premise of the feature is that the orphaned Dart callback keeps running and polling isTaskCancelled() for a while *after* that — Dart has no way to be interrupted. So the sequence was: markCancelled() → (this coroutine returns, finally fires) → clear() → every subsequent poll from the still-running callback sees false again. Confirmed via logcat: the poll immediately after the mark still returned false, and the counterFile kept climbing 10 more iterations in the following 2s — cancellation was never actually observed by the callback on a real device, even though every unit test (channel mocked, no real coroutine-cancellation race) stayed green. Every unit test in the previous commits mocked the channel and never exercised the real timing race between "this coroutine's cancellation unwinds" and "the orphaned Dart isolate's next poll" — so nothing caught this until the device test that was supposed to prove the fix actually ran on real hardware. Fix: clear the registry from the three MethodChannel.Result overrides on the executeCallback invocation (success/error/notImplemented) — which fire whenever that specific invocation truly finishes, including when it finishes late, well after this coroutine already unwound from cancellation — not from a finally tied to this coroutine's own lifetime. Verified on the Pixel 6 Pro: issue_66 now passes, and logcat shows the first poll after markCancelled() correctly returns true. Reran Cancellation, Issue #38/#39, Task Chains, and All Workers device-test groups (no regressions) plus the full Gradle unit test suite (green). Claude-Session: https://claude.ai/code/session_011aaZsf2gDsriP8hfMXbgG8 --- .../engine/FlutterEngineManager.kt | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt index 7a4dc48..81b60ea 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt @@ -92,23 +92,27 @@ object FlutterEngineManager { // Issue #66: when non-null, lets this function mark // DartTaskCancellationRegistry on external cancellation so // NativeWorkManager.isTaskCancelled(taskId) is observable from inside - // the still-running Dart callback. Always cleared before returning. + // the still-running Dart callback. + // + // Cleared from the executeCallback MethodChannel.Result callbacks in + // executeDartCallbackInternal, NOT from a finally block here. Device + // testing (Pixel 6 Pro) caught a real bug in an earlier version of + // this fix: clearing here ran the instant THIS coroutine unwound after + // observing cancellation — but the orphaned Dart callback keeps + // running for a while after that (that's the entire premise of + // cooperative cancellation) and kept polling isTaskCancelled() long + // after this function had already returned. Clearing synchronously + // here raced the mark-then-immediately-clear into a window so narrow + // the callback's very next poll already saw `false` again — silently + // defeating the whole feature while every unit test (which mocks the + // channel and never runs the real coroutine-cancellation race) stayed + // green. Must be cleared only when the real invocation this taskId + // was registered for actually finishes. taskId: String? = null ): Boolean = withContext(Dispatchers.Main) { - try { - executeDartCallbackInternal( - context, callbackHandle, input, timeoutMs, disposeImmediately, taskId - ) - } finally { - // Issue #66: always drop this taskId's cancellation-registry entry - // once execution is done, on every exit path (success, failure, - // timeout, or cancellation) — kept in one place, outside the - // catch blocks below, so it cannot interfere with the - // `catch (e: CancellationException) { throw e }` shape that - // cancellation_rethrow_invariant_test.dart requires immediately - // after the brace. - if (taskId != null) DartTaskCancellationRegistry.clear(taskId) - } + executeDartCallbackInternal( + context, callbackHandle, input, timeoutMs, disposeImmediately, taskId + ) } private suspend fun executeDartCallbackInternal( @@ -158,14 +162,26 @@ object FlutterEngineManager { "timeoutMs" to timeoutMs ) + // Issue #66: these three overrides fire whenever this specific + // executeCallback invocation truly finishes — including when it + // finishes LATE, after resultDeferred.await() below has already + // been cancelled (see the comment on executeDartCallback above). + // That makes this the only correct place to clear taskId's + // DartTaskCancellationRegistry entry: clearing it here, not in a + // finally around the cancelled-coroutine's own unwind, is what + // lets isTaskCancelled() keep returning true for as long as the + // orphaned callback is still polling it. channel.invokeMethod("executeCallback", args, object : MethodChannel.Result { override fun success(result: Any?) { + if (taskId != null) DartTaskCancellationRegistry.clear(taskId) resultDeferred.complete((result as? Boolean) ?: false) } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + if (taskId != null) DartTaskCancellationRegistry.clear(taskId) resultDeferred.complete(false) } override fun notImplemented() { + if (taskId != null) DartTaskCancellationRegistry.clear(taskId) resultDeferred.complete(false) } }) From 9e09802170b80f153ab58c7abdca9f44dcac06c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 9 Sep 2026 23:15:59 +0700 Subject: [PATCH 4/9] docs(changelog): reflect the device-verification correction Claude-Session: https://claude.ai/code/session_011aaZsf2gDsriP8hfMXbgG8 --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4830b9a..bb6f26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cancellation), iOS foreground/simulator (main-isolate `activeTasks` cancel), and iOS true-background BGTask expiration. See `DartTaskCancellationRegistry` (Kotlin and Swift) and the `issue_66_*` - entries in `device_integration_test.dart`. + entries in `device_integration_test.dart`. Device-verified on a Pixel 6 + Pro and an iOS simulator — the Android half was a no-op until the + registry-clear-timing fix below. ### Fixed @@ -29,6 +31,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 orphaned callback was still executing.** `FlutterEngineManager .executeDartCallback` rethrew external `CancellationException` before its own dispose/idle-timer logic ever ran. Found investigating #66. +- **Android: `isTaskCancelled(taskId)` cleared its own answer the instant + it was set, making the feature above a no-op on real hardware** — the + registry entry was cleared from a `finally` tied to the cancelling + coroutine's own lifetime, but the orphaned Dart callback keeps polling + for a while *after* that coroutine unwinds (the entire premise of + cooperative cancellation). Every unit test stayed green because a + mocked channel can't reproduce this timing race. Found only once a + real device became available to run the `issue_66` device test on. - **iOS: `BGTaskSchedulerManager` never actually cancelled the running `Task` on BGTask expiration** — only `activeWorker.stop()` was called (a no-op for `DartCallbackWorker`), so the work backing an expired task kept From 303fdc2276f6f77185978af6a4454bbdefe9f0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 9 Sep 2026 23:47:16 +0700 Subject: [PATCH 5/9] test(ftl): add Firebase Test Lab instrumentation harness for issue #66/#69 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pieces needed to run integration_test files as Android instrumentation tests on Firebase Test Lab, which the repo didn't have (the weekly firebase-benchmark.yml workflow has never actually run — its own comment notes the required secrets were never configured): - example/android/app/src/androidTest/.../MainActivityTest.kt — the standard Flutter FlutterTestRunner wrapper (from the flutter/packages integration_test example), drives whichever integration_test Dart entrypoint was baked into the app APK. - testInstrumentationRunner + androidx.test:runner/rules deps in app/build.gradle.kts, pinned to 1.2.0 to match what the integration_test plugin's own Android dependency already resolves to on the main runtime classpath (Gradle's consistent-resolution check fails otherwise). - integration_test/issue_66_69_ftl_test.dart — a small, self-contained entrypoint covering just the two cancellation regressions (issue #66, issue #69), NOT the full device_integration_test.dart suite. That suite is long-running and has known hang risks on periodic-trigger tests (see project memory) — unsuitable for a quick, cheap Test Lab spot-check, which is what this is for. - scripts/firebase-ftl-cancellation.sh — builds both APKs, submits to Test Lab, and polls for the result via the Testing API's REST endpoint directly (this environment's gcloud CLI has no `matrices describe` subcommand under `firebase test android`). Claude-Session: https://claude.ai/code/session_011aaZsf2gDsriP8hfMXbgG8 --- example/android/app/build.gradle.kts | 13 ++ .../MainActivityTest.kt | 22 +++ .../issue_66_69_ftl_test.dart | 170 ++++++++++++++++++ scripts/firebase-ftl-cancellation.sh | 131 ++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 example/android/app/src/androidTest/kotlin/dev/brewkits/native_workmanager_example/MainActivityTest.kt create mode 100644 example/integration_test/issue_66_69_ftl_test.dart create mode 100755 scripts/firebase-ftl-cancellation.sh diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts index 6b1ddfc..bbf24a8 100644 --- a/example/android/app/build.gradle.kts +++ b/example/android/app/build.gradle.kts @@ -29,6 +29,11 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // Lets the androidTest source set (MainActivityTest.kt) drive whichever + // integration_test Dart entrypoint was baked into this APK via + // `flutter build apk --target=integration_test/.dart` — needed to + // run on Firebase Test Lab as an instrumentation test. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -46,4 +51,12 @@ flutter { dependencies { // Kotlinx Serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + + // Firebase Test Lab instrumentation wrapper (MainActivityTest.kt). + // Pinned to 1.2.0 to match the version the integration_test plugin's own + // Android dependency already resolves to on :app:debugRuntimeClasspath — + // Gradle's consistent-resolution check fails the androidTest classpath + // otherwise (it must resolve every shared module to the same version). + androidTestImplementation("androidx.test:runner:1.2.0") + androidTestImplementation("androidx.test:rules:1.2.0") } diff --git a/example/android/app/src/androidTest/kotlin/dev/brewkits/native_workmanager_example/MainActivityTest.kt b/example/android/app/src/androidTest/kotlin/dev/brewkits/native_workmanager_example/MainActivityTest.kt new file mode 100644 index 0000000..a4c19f9 --- /dev/null +++ b/example/android/app/src/androidTest/kotlin/dev/brewkits/native_workmanager_example/MainActivityTest.kt @@ -0,0 +1,22 @@ +package dev.brewkits.native_workmanager_example + +import androidx.test.rule.ActivityTestRule +import dev.flutter.plugins.integration_test.FlutterTestRunner +import org.junit.Rule +import org.junit.runner.RunWith + +/** + * Wraps whichever `integration_test` Dart entrypoint was baked into the app + * APK (via `flutter build apk --target=integration_test/.dart`) as an + * Android instrumentation test, so it can run on Firebase Test Lab. + * + * See `scripts/firebase-ftl-cancellation.sh` (issue #66/#69) and + * `scripts/firebase-benchmark.sh` (weekly benchmark) for the two Dart + * entrypoints this wrapper is used to drive. + */ +@RunWith(FlutterTestRunner::class) +class MainActivityTest { + @Rule + @JvmField + val rule = ActivityTestRule(MainActivity::class.java, false, false) +} diff --git a/example/integration_test/issue_66_69_ftl_test.dart b/example/integration_test/issue_66_69_ftl_test.dart new file mode 100644 index 0000000..7b12a39 --- /dev/null +++ b/example/integration_test/issue_66_69_ftl_test.dart @@ -0,0 +1,170 @@ +// ignore_for_file: avoid_print +// ============================================================ +// Issue #66 / #69 — Firebase Test Lab regression check +// ============================================================ +// +// Deliberately separate from device_integration_test.dart: that file's full +// suite is long-running and known to hang certain CI/emulator setups on +// periodic-trigger tests (see CLAUDE.md / project memory). This file exists +// so the two cancellation-cooperation regressions from +// https://github.com/brewkits/native_workmanager/discussions/66 and +// https://github.com/brewkits/native_workmanager/issues/69 can be re-verified +// quickly and cheaply across a real device matrix on Firebase Test Lab, +// independent of the rest of the suite. +// +// Run locally the same way any integration_test file runs: +// flutter test integration_test/issue_66_69_ftl_test.dart +// +// For Firebase Test Lab, this file is built as the app's entrypoint via +// `flutter build apk --target=integration_test/issue_66_69_ftl_test.dart` +// (Android) and the equivalent `-target` for iOS's XCTest bridge, then run +// as an instrumentation/XCTest against a real device matrix. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; + +String _id(String name) => + 'ftl_${name}_${DateTime.now().millisecondsSinceEpoch}'; + +/// Issue #66: polls [NativeWorkManager.isTaskCancelled] between chunks of +/// "work" and writes its iteration count to [input]'s `counterFile`, so the +/// test can prove it bailed out early instead of running to completion (50 +/// iterations x 200ms = 10s if never cancelled). +@pragma('vm:entry-point') +Future _ftlCancelPoll(Map? input) async { + final taskId = input?['__taskId'] as String?; + final counterFile = input?['counterFile'] as String?; + for (var i = 1; i <= 50; i++) { + if (counterFile != null) { + File(counterFile).writeAsStringSync('$i'); + } + if (taskId != null && await NativeWorkManager.isTaskCancelled(taskId)) { + print('[FTL] cancel_poll: observed cancellation at iteration $i'); + return false; + } + await Future.delayed(const Duration(milliseconds: 200)); + } + print('[FTL] cancel_poll: completed all iterations uncancelled'); + return true; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tmpDir; + + setUpAll(() async { + tmpDir = Directory( + '${Directory.systemTemp.path}/nwm_ftl_${DateTime.now().millisecondsSinceEpoch}', + )..createSync(); + + await NativeWorkManager.initialize( + dartWorkers: {'ftl_cancel_poll': _ftlCancelPoll}, + ); + await NativeWorkManager.cancelAll(); + }); + + tearDownAll(() async { + await NativeWorkManager.cancelAll(); + tmpDir.deleteSync(recursive: true); + }); + + group('Issue #66/#69 — Firebase Test Lab regression check', () { + testWidgets( + 'issue_66: cancelling a running DartWorker task is observable via isTaskCancelled', + (tester) async { + // See example/integration_test/device_integration_test.dart for the + // full-detail version of this test and the history of the bug: the + // registry backing isTaskCancelled() was cleared the instant it was + // marked, so this passed against a mocked channel while being a + // complete no-op on real hardware. Re-verifying on real devices is + // exactly the point of this file. + final id = _id('cancel_poll'); + final counterFile = File('${tmpDir.path}/issue_66_counter.txt'); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: DartWorker( + callbackId: 'ftl_cancel_poll', + input: {'counterFile': counterFile.path}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 600)); + await NativeWorkManager.cancel(taskId: id); + + await Future.delayed(const Duration(seconds: 2)); + + expect( + counterFile.existsSync(), + isTrue, + reason: 'issue_66: the callback must have started and written at ' + 'least one iteration before being cancelled', + ); + final iterationsAtCancel = + int.parse(counterFile.readAsStringSync().trim()); + expect( + iterationsAtCancel, + lessThan(20), + reason: 'issue_66: cancelling ~600ms in must stop the callback ' + 'well short of all 50 iterations — a count this high means ' + 'isTaskCancelled() never observed the cancellation', + ); + + final iterationsAfterWait = + int.parse(counterFile.readAsStringSync().trim()); + await Future.delayed(const Duration(seconds: 2)); + final iterationsStillAfterWait = + int.parse(counterFile.readAsStringSync().trim()); + expect( + iterationsStillAfterWait, + equals(iterationsAfterWait), + reason: 'issue_66: iteration count must not still be climbing ' + '2s later — the callback should have returned, not kept ' + 'working', + ); + }, + ); + + testWidgets( + 'issue_69: cancelling a background-session download actually aborts the transfer (iOS)', + (tester) async { + if (!Platform.isIOS) { + markTestSkipped( + 'useBackgroundSession is iOS-only on ${Platform.operatingSystem}', + ); + return; + } + + final id = _id('bg_download_cancel'); + final savePath = '${tmpDir.path}/issue_69_bg_download.bin'; + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: HttpDownloadWorker( + url: 'https://httpbin.org/delay/6', + savePath: savePath, + useBackgroundSession: true, + ), + constraints: const Constraints(requiresNetwork: true), + ); + + await Future.delayed(const Duration(seconds: 1)); + await NativeWorkManager.cancel(taskId: id); + + await Future.delayed(const Duration(seconds: 8)); + + expect( + File(savePath).existsSync(), + isFalse, + reason: 'issue_69: a cancelled background-session download must ' + 'not still write its destination file', + ); + }, + ); + }); +} diff --git a/scripts/firebase-ftl-cancellation.sh b/scripts/firebase-ftl-cancellation.sh new file mode 100755 index 0000000..c938fb8 --- /dev/null +++ b/scripts/firebase-ftl-cancellation.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# ============================================================ +# Firebase Test Lab — issue #66 / #69 cancellation regression check +# ============================================================ +# +# Builds the example app with integration_test/issue_66_69_ftl_test.dart as +# its entrypoint and runs it as an Android instrumentation test on a real +# device matrix via Firebase Test Lab. Deliberately separate from +# firebase-benchmark.sh: that script drives firebase_benchmark_test.dart +# (perf numbers, long-running); this one drives a short, cheap correctness +# check (~10s per device) so it's realistic to run ad hoc, not just weekly. +# +# Prerequisites: +# - gcloud CLI authenticated to a project with the Cloud Testing API +# (testing.googleapis.com) and billing enabled +# - flutter in PATH +# - example/android/app/build.gradle.kts has testInstrumentationRunner + +# androidx.test:runner/rules configured, and +# example/android/app/src/androidTest/.../MainActivityTest.kt exists +# (both already committed — this script does not set them up) +# +# Usage: +# FIREBASE_PROJECT_ID= ./scripts/firebase-ftl-cancellation.sh +# +# Environment variables: +# FIREBASE_PROJECT_ID — required: your Firebase/GCP project ID +# DEVICES — optional: space-separated "model=X,version=Y" +# pairs. Default: a small 3-device spot check +# (one stock virtual baseline + two OEM +# battery-layer physical devices, matching the +# reasoning in benchmark/firebase-device-matrix.json) +# TIMEOUT — optional: per-device instrumentation timeout +# (default: 300s — generous for a ~10s test; the +# slack covers device allocation/boot overhead) +# +# Output: prints the Firebase Test Lab console URL and polls until the +# matrix finishes, then prints a pass/fail summary per device. +# ============================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +EXAMPLE_DIR="$REPO_ROOT/example" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +info() { echo -e "${BLUE}ℹ️ $*${NC}"; } +ok() { echo -e "${GREEN}✅ $*${NC}"; } +warn() { echo -e "${YELLOW}⚠️ $*${NC}"; } +error() { echo -e "${RED}❌ $*${NC}"; exit 1; } + +command -v gcloud >/dev/null 2>&1 || error "gcloud CLI not found. Install from https://cloud.google.com/sdk" +command -v flutter >/dev/null 2>&1 || error "Flutter not found" +[[ -z "${FIREBASE_PROJECT_ID:-}" ]] && error "FIREBASE_PROJECT_ID is not set" + +TIMEOUT="${TIMEOUT:-300s}" +DEVICES="${DEVICES:-model=MediumPhone.arm,version=34,locale=en,orientation=portrait model=SC-51E,version=36,locale=en,orientation=portrait model=OP573DL1,version=34,locale=en,orientation=portrait}" + +ok "Prerequisites OK — project: $FIREBASE_PROJECT_ID" + +info "Building app APK (integration_test/issue_66_69_ftl_test.dart as entrypoint)..." +pushd "$EXAMPLE_DIR" > /dev/null +flutter pub get +flutter build apk --debug --target=integration_test/issue_66_69_ftl_test.dart 2>&1 | tail -5 + +info "Building androidTest instrumentation APK..." +pushd android > /dev/null +./gradlew app:assembleAndroidTest --stacktrace 2>&1 | tail -10 +popd > /dev/null + +APP_APK="build/app/outputs/flutter-apk/app-debug.apk" +TEST_APK="build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk" +[[ -f "$APP_APK" ]] || error "App APK not found at $APP_APK" +[[ -f "$TEST_APK" ]] || error "Test APK not found at $TEST_APK" +ok "Both APKs built" + +# Turn "$DEVICES" into repeated --device flags. +device_args=() +for d in $DEVICES; do + device_args+=(--device "$d") +done + +info "Submitting to Firebase Test Lab (project: $FIREBASE_PROJECT_ID)..." +run_output=$(gcloud firebase test android run \ + --type instrumentation \ + --app "$APP_APK" \ + --test "$TEST_APK" \ + --timeout "$TIMEOUT" \ + --project "$FIREBASE_PROJECT_ID" \ + --async \ + "${device_args[@]}" 2>&1) +echo "$run_output" + +matrix_id=$(echo "$run_output" | grep -oE 'matrix-[a-z0-9]+' | head -1) +[[ -z "$matrix_id" ]] && error "Could not parse matrix ID from gcloud output" +ok "Submitted: $matrix_id" + +popd > /dev/null + +# The gcloud CLI version this was written against has no `matrices describe` +# subcommand under `firebase test android` (only locales/models/versions/run) +# — polling goes straight to the Testing API's REST endpoint instead. +info "Polling for completion via the Testing API (this can take a few minutes for device allocation)..." +while true; do + token=$(gcloud auth print-access-token) + resp=$(curl -s -H "Authorization: Bearer $token" \ + "https://testing.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/testMatrices/${matrix_id}") + state=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('state','UNKNOWN'))" <<< "$resp") + case "$state" in + FINISHED|ERROR|INVALID|UNSUPPORTED_ENVIRONMENT|INCOMPATIBLE_ENVIRONMENT) + break + ;; + esac + sleep 20 +done + +echo "" +python3 -c " +import sys, json +d = json.load(sys.stdin) +for e in d.get('testExecutions', []): + dev = e.get('environment', {}).get('androidDevice', {}) + print(f\"{dev.get('androidModelId')} / API{dev.get('androidVersionId')}: \" + f\"state={e.get('state')} result={e.get('testResult', 'N/A')}\") +" <<< "$resp" + +if [[ "$state" == "FINISHED" ]]; then + ok "Matrix finished — check outcomes above (a device row can still be FAILED even when the matrix itself FINISHED)" +else + warn "Matrix ended in state: $state — see console for details" +fi From c6ef688d70fc49767b033c858cb066e2df9a1a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 10 Sep 2026 07:17:03 +0700 Subject: [PATCH 6/9] fix(ci): actually pin Flutter via .flutter-version, not floating stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subosito/flutter-action's `channel` input defaults to 'stable' even when the key is omitted from the workflow yaml entirely, and a non-empty channel wins over flutter-version-file. The existing fix (just omitting `channel:`) left that default in place, so .flutter-version was still being silently ignored on every job — confirmed today: the pin says 3.41.9, but every job in this PR actually ran 3.47.2, and its `dart format` disagreed with local (3.41.9), turning an unrelated PR red. Fix: explicitly set `channel: ''` alongside flutter-version-file so there is no non-empty default left for the action to prefer. --- .github/workflows/ci.yml | 66 +++++++++++++++------ .github/workflows/firebase-benchmark.yml | 22 +++++-- .github/workflows/native_workmanager_ci.yml | 33 ++++++++--- .github/workflows/pr-check.yml | 11 +++- .github/workflows/publish.yml | 33 ++++++++--- 5 files changed, 120 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf99b96..ece2a9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies (plugin) @@ -85,9 +90,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies @@ -120,9 +130,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies (plugin — needed by gen) @@ -156,9 +171,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - uses: actions/setup-java@v4 @@ -191,9 +211,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies @@ -236,9 +261,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies diff --git a/.github/workflows/firebase-benchmark.yml b/.github/workflows/firebase-benchmark.yml index a2d13de..54eda52 100644 --- a/.github/workflows/firebase-benchmark.yml +++ b/.github/workflows/firebase-benchmark.yml @@ -81,9 +81,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - uses: actions/setup-java@v4 @@ -190,9 +195,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Authenticate to Google Cloud (Workload Identity) diff --git a/.github/workflows/native_workmanager_ci.yml b/.github/workflows/native_workmanager_ci.yml index 88a4819..0635a3b 100644 --- a/.github/workflows/native_workmanager_ci.yml +++ b/.github/workflows/native_workmanager_ci.yml @@ -17,9 +17,14 @@ jobs: uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Install dependencies @@ -44,9 +49,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - uses: actions/setup-java@v4 @@ -75,9 +85,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Install dependencies diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 0b17f3b..f1dbf4a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -89,9 +89,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies (plugin) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8f0b9eb..086e6e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,9 +30,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Verify plugin version matches input @@ -93,9 +98,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies @@ -128,9 +138,14 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version-file: .flutter-version - # No `channel:` here on purpose — subosito/flutter-action lets channel - # WIN over flutter-version-file, so having both meant .flutter-version was - # silently ignored and every job ran whatever stable happened to be latest. + # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` + # input defaults to 'stable' even when this key is omitted entirely, and a + # non-empty channel wins over flutter-version-file. Omitting the key (the + # previous fix, see git blame) left that default in place, so + # .flutter-version kept being silently ignored and CI floated to whatever + # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, + # CI actually ran 3.47.2 and reformatted files differently than local. + channel: '' cache: true - name: Get dependencies (plugin — needed as path dep during local dev) From 3c1152d394c3d9b2b9bca9073926b6fe112406cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 10 Sep 2026 07:25:37 +0700 Subject: [PATCH 7/9] =?UTF-8?q?fix(ci):=20pin=20Flutter=20via=20.fvmrc=20?= =?UTF-8?q?=E2=80=94=20.flutter-version=20was=20never=20a=20supported=20fo?= =?UTF-8?q?rmat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subosito/flutter-action's flutter-version-file only parses pubspec.yaml, .fvmrc, or .fvm/fvm_config.json. This repo pointed it at a plain-text .flutter-version file, which the action can't parse in any supported format — it silently fell back to the channel default ('stable', which is non-empty even when the key is omitted from this yaml), so every job ran whatever Flutter happened to be newest that day regardless of the pin. Confirmed today: pin said 3.41.9, CI ran 3.47.2, then 3.47.3 the next run, each formatting lib/src/native_work_manager.dart and test/unit/issue_66_dart_worker_cancellation_test.dart differently than local — the actual cause of this PR's unrelated CI failures. `channel` is also explicitly emptied as defense in depth, in case both ever resolve non-empty again. The project already manages Flutter locally via `fvm`, so .fvmrc is also the natural single source of truth for local `fvm use`. Also: - native_workmanager_gen had no analysis_options.yaml of its own, so `dart analyze` walked up to the root plugin's, which includes `package:flutter_lints/flutter.yaml` — unresolvable against a pure-Dart package that only depends on `lints`. That silently broke analysis for the whole package (every run just warned and skipped), hiding an unused import and two leading-underscore locals in its test suite. Gave it its own analysis_options.yaml (package:lints/recommended.yaml) and fixed the newly-surfaced issues. - Extended test/unit/channel_method_parity_test.dart to cover dev.brewkits/dart_worker_channel (dartReady/reportProgress/ isTaskCancelled on both platforms' handlers), which the existing parity test explicitly exempted from any automated check — the exact "Dart calls a method no platform registers" defect class this repo has shipped five times, just on the channel the guard didn't reach yet. Red-then-green verified against a real regression in FlutterEngineManager.kt. --- .flutter-version | 1 - .fvmrc | 3 + .github/workflows/ci.yml | 132 +++++++++++------- .github/workflows/firebase-benchmark.yml | 44 +++--- .github/workflows/native_workmanager_ci.yml | 66 +++++---- .github/workflows/pr-check.yml | 22 +-- .github/workflows/publish.yml | 66 +++++---- lib/src/native_work_manager.dart | 4 +- native_workmanager_gen/analysis_options.yaml | 12 ++ .../test/worker_callback_generator_test.dart | 9 +- test/unit/channel_method_parity_test.dart | 100 +++++++++++++ ...ssue_66_dart_worker_cancellation_test.dart | 7 +- 12 files changed, 336 insertions(+), 130 deletions(-) delete mode 100644 .flutter-version create mode 100644 .fvmrc create mode 100644 native_workmanager_gen/analysis_options.yaml diff --git a/.flutter-version b/.flutter-version deleted file mode 100644 index 1b36d32..0000000 --- a/.flutter-version +++ /dev/null @@ -1 +0,0 @@ -3.41.9 diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..084b2bc --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.41.9" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ece2a9b..e61bb8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,14 +23,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -89,14 +95,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -129,14 +141,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -170,14 +188,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -210,14 +234,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -260,14 +290,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true diff --git a/.github/workflows/firebase-benchmark.yml b/.github/workflows/firebase-benchmark.yml index 54eda52..4eecd75 100644 --- a/.github/workflows/firebase-benchmark.yml +++ b/.github/workflows/firebase-benchmark.yml @@ -80,14 +80,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -194,14 +200,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true diff --git a/.github/workflows/native_workmanager_ci.yml b/.github/workflows/native_workmanager_ci.yml index 0635a3b..7964d39 100644 --- a/.github/workflows/native_workmanager_ci.yml +++ b/.github/workflows/native_workmanager_ci.yml @@ -16,14 +16,20 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -48,14 +54,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -84,14 +96,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index f1dbf4a..8914a2b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -88,14 +88,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 086e6e3..bf94ed4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,14 +29,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -97,14 +103,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true @@ -137,14 +149,20 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version-file: .flutter-version - # `channel` MUST be explicitly emptied. subosito/flutter-action's `channel` - # input defaults to 'stable' even when this key is omitted entirely, and a - # non-empty channel wins over flutter-version-file. Omitting the key (the - # previous fix, see git blame) left that default in place, so - # .flutter-version kept being silently ignored and CI floated to whatever - # `stable` happened to be that day. Confirmed 2026-09-10: pin was 3.41.9, - # CI actually ran 3.47.2 and reformatted files differently than local. + flutter-version-file: .fvmrc + # MUST be .fvmrc, not a plain .flutter-version text file. subosito/ + # flutter-action's flutter-version-file only parses pubspec.yaml, + # .fvmrc or .fvm/fvm_config.json — a bare "3.41.9" file (the previous + # setup) silently failed to parse and fell back to the `channel` + # default of 'stable', floating to whatever was newest that day. + # `channel` is explicitly emptied too, since that default is + # non-empty even when this key is omitted and a non-empty channel + # wins over flutter-version-file when both resolve. Confirmed + # 2026-09-10: the plain-text pin said 3.41.9, CI actually ran 3.47.2 + # (then 3.47.3 the next run) and reformatted files differently than + # local, turning an unrelated PR red. This project already manages + # Flutter locally via `fvm`, so .fvmrc is also the natural single + # source of truth for local `fvm use`. channel: '' cache: true diff --git a/lib/src/native_work_manager.dart b/lib/src/native_work_manager.dart index 5541471..36f33f6 100644 --- a/lib/src/native_work_manager.dart +++ b/lib/src/native_work_manager.dart @@ -1111,8 +1111,8 @@ class NativeWorkManager { if (taskId.isEmpty) return false; const channel = MethodChannel('dev.brewkits/dart_worker_channel'); try { - final result = await channel - .invokeMethod('isTaskCancelled', { + final result = + await channel.invokeMethod('isTaskCancelled', { 'taskId': taskId, }); return result ?? false; diff --git a/native_workmanager_gen/analysis_options.yaml b/native_workmanager_gen/analysis_options.yaml new file mode 100644 index 0000000..3a5069b --- /dev/null +++ b/native_workmanager_gen/analysis_options.yaml @@ -0,0 +1,12 @@ +# native_workmanager_gen is a pure-Dart package (build_runner code generator) +# with no Flutter dependency — it must not inherit the root plugin's +# `package:flutter_lints/flutter.yaml`. Without this file, `dart analyze` +# walked up to the root's analysis_options.yaml and failed to resolve +# flutter_lints against this package's own pub cache (it only depends on +# `lints`), producing a "Failed to resolve package URI" warning on every run. +include: package:lints/recommended.yaml + +analyzer: + exclude: + - build/** + - example/** diff --git a/native_workmanager_gen/test/worker_callback_generator_test.dart b/native_workmanager_gen/test/worker_callback_generator_test.dart index 2128a11..cae308c 100644 --- a/native_workmanager_gen/test/worker_callback_generator_test.dart +++ b/native_workmanager_gen/test/worker_callback_generator_test.dart @@ -1,7 +1,6 @@ import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:native_workmanager_gen/builder.dart'; -import 'package:source_gen/source_gen.dart'; import 'package:test/test.dart'; /// Tests for [WorkerCallbackGenerator] via the [workerCallbackBuilder] @@ -29,7 +28,7 @@ import 'package:test/test.dart'; void main() { Builder builder() => workerCallbackBuilder(BuilderOptions.empty); - const _annotationSource = ''' + const annotationSource = ''' class WorkerCallback { final String id; final Type? inputType; @@ -37,15 +36,15 @@ class WorkerCallback { } '''; - const _import = + const annotationImport = "import 'package:native_workmanager/src/worker_callback_generator_annotation.dart';"; /// Builds the asset map for a `lib/workers.dart` source, plus the virtual /// `native_workmanager` annotation package every fixture imports. Map assets(String workersSource) => { 'native_workmanager|lib/src/worker_callback_generator_annotation.dart': - _annotationSource, - 'a|lib/workers.dart': '$_import\n\n$workersSource', + annotationSource, + 'a|lib/workers.dart': '$annotationImport\n\n$workersSource', }; const outputAsset = 'a|lib/workers.worker_callback.g.part'; diff --git a/test/unit/channel_method_parity_test.dart b/test/unit/channel_method_parity_test.dart index ca1a72e..5b272c0 100644 --- a/test/unit/channel_method_parity_test.dart +++ b/test/unit/channel_method_parity_test.dart @@ -166,4 +166,104 @@ void main() { } }); }); + + // Everything above is exempted from the main-channel checks (they're sent on + // MethodChannel('dev.brewkits/dart_worker_channel') instead), which left them + // with no automated check at all — the exact shape this whole file exists to + // catch, just one channel over. Closed 2026-09-10 while reviewing #66/#68: + // 'isTaskCancelled' had a written-out "iOS also answers it from ..." comment + // that no test actually verified. + group('dart_worker_channel parity (the gap _notOnMainChannel left open)', () { + Set androidHandledMethods() { + final src = _repoFile( + 'android/src/main/kotlin/dev/brewkits/native_workmanager/' + 'engine/FlutterEngineManager.kt', + ).readAsStringSync(); + return RegExp(r'"([a-zA-Z_]+)"\s*->') + .allMatches(src) + .map((m) => m.group(1)!) + .toSet(); + } + + Set iosHeadlessHandledMethods() { + final src = _repoFile( + 'ios/native_workmanager/Sources/native_workmanager/' + 'engine/FlutterEngineManager.swift', + ).readAsStringSync(); + return RegExp(r'call\.method\s*==\s*"([a-zA-Z_]+)"') + .allMatches(src) + .map((m) => m.group(1)!) + .toSet(); + } + + Set iosForegroundHandledMethods() { + // NativeWorkmanagerPlugin.swift's own dartWorkerChannel — the main-isolate + // handler for foreground/test-mode DartWorker callbacks (see + // CLAUDE.md "Execution Modes"). Android has no equivalent: it always + // executes DartWorker callbacks through the headless engine above. + final src = _repoFile( + 'ios/native_workmanager/Sources/native_workmanager/' + 'NativeWorkmanagerPlugin.swift', + ).readAsStringSync(); + final marker = src.indexOf('dartWorkerChannel?.setMethodCallHandler'); + expect( + marker, + greaterThanOrEqualTo(0), + reason: 'NativeWorkmanagerPlugin.swift no longer wires the foreground ' + 'dartWorkerChannel handler — if this moved, update this test\'s ' + 'anchor rather than deleting the check', + ); + final handlerBody = src.substring(marker); + return RegExp(r'case\s+"([a-zA-Z_]+)"') + .allMatches(handlerBody) + .map((m) => m.group(1)!) + .toSet(); + } + + // dartReady only matters to the headless engine (it signals the isolate is + // up before enqueuing work) — the foreground path has no isolate to wait + // for, so it is deliberately absent there. + const headlessOnlyMethods = {'dartReady'}; + // reportProgress and isTaskCancelled must work identically no matter which + // engine a DartWorker callback happens to be running on. + const bothEnginesMethods = {'reportProgress', 'isTaskCancelled'}; + + test('Android handles every dart_worker_channel method', () { + final handled = androidHandledMethods(); + final missing = + {...headlessOnlyMethods, ...bothEnginesMethods}.difference(handled); + expect( + missing, + isEmpty, + reason: 'dev.brewkits/dart_worker_channel methods with no Android ' + 'FlutterEngineManager.kt handler: $missing', + ); + }); + + test('iOS headless engine handles every dart_worker_channel method', () { + final handled = iosHeadlessHandledMethods(); + final missing = + {...headlessOnlyMethods, ...bothEnginesMethods}.difference(handled); + expect( + missing, + isEmpty, + reason: 'dev.brewkits/dart_worker_channel methods with no iOS ' + 'FlutterEngineManager.swift handler: $missing', + ); + }); + + test('iOS foreground engine handles the methods it must', () { + final handled = iosForegroundHandledMethods(); + final missing = bothEnginesMethods.difference(handled); + expect( + missing, + isEmpty, + reason: 'dev.brewkits/dart_worker_channel methods with no iOS ' + 'foreground dartWorkerChannel handler in ' + 'NativeWorkmanagerPlugin.swift: $missing — a callback running in ' + 'foreground/test mode (see CLAUDE.md "Execution Modes") would ' + 'throw MissingPluginException calling this', + ); + }); + }); } diff --git a/test/unit/issue_66_dart_worker_cancellation_test.dart b/test/unit/issue_66_dart_worker_cancellation_test.dart index f488eb2..117f1ee 100644 --- a/test/unit/issue_66_dart_worker_cancellation_test.dart +++ b/test/unit/issue_66_dart_worker_cancellation_test.dart @@ -38,7 +38,8 @@ void main() { }); group('isTaskCancelled', () { - test('returns false without a platform call when taskId is empty', () async { + test('returns false without a platform call when taskId is empty', + () async { final result = await NativeWorkManager.isTaskCancelled(''); expect(result, isFalse); expect(calls, isEmpty); @@ -59,7 +60,9 @@ void main() { expect(calls.single.arguments, {'taskId': 'cancelled-task'}); }); - test('returns false, not an exception, if the platform channel has no handler', () async { + test( + 'returns false, not an exception, if the platform channel has no handler', + () async { // Simulates a platform that has not registered a handler for this // method on this channel yet — MissingPluginException must not crash // a DartWorker callback that is only trying to check cancellation. From 5b26891f2ff9f5b627ed2f8e4c2c5bd26ef998a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 10 Sep 2026 07:48:19 +0700 Subject: [PATCH 8/9] =?UTF-8?q?chore(release):=20version=201.7.0=20?= =?UTF-8?q?=E2=80=94=20isTaskCancelled=20demo=20card,=20docs,=20pana=20160?= =?UTF-8?q?/160?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump native_workmanager, native_workmanager_gen, and the example app to 1.7.0 in lockstep. CHANGELOG headers moved from [Unreleased] to [1.7.0] - 2026-09-10 in both packages. - Updated every doc install snippet (README, GETTING_STARTED, MIGRATION_GUIDE, MIGRATION_TOOL_README, ANDROID_SETUP) from ^1.6.1 to ^1.7.0. - Added a "1c. Dart Worker — Cooperative Cancellation (Issue #66)" demo card to the example app's comprehensive demo page, plus its backing `cancellableTaskCallback` in main.dart — the new isTaskCancelled API had zero demo coverage before this. Device-verified the underlying mechanism via the existing issue_66/issue_69 device_integration_test.dart Cancellation group (4/4 passing on iOS simulator). - CHANGELOG "Known Issues" entry documenting a pre-existing, cross-platform DartWorker.timeoutMs event-delivery gap found while auditing the stress suite for this release — reproduced on main at v1.6.1 via a worktree comparison, so unrelated to and not blocking this release, but real and worth its own investigation. See CHANGELOG for the full write-up. - pana: 160/160 on both native_workmanager and native_workmanager_gen. --- CHANGELOG.md | 40 ++++++++++++++++++- README.md | 2 +- doc/ANDROID_SETUP.md | 2 +- doc/GETTING_STARTED.md | 2 +- doc/MIGRATION_GUIDE.md | 4 +- doc/MIGRATION_TOOL_README.md | 2 +- example/lib/main.dart | 25 ++++++++++++ .../lib/pages/comprehensive_demo_page.dart | 39 ++++++++++++++++++ example/pubspec.lock | 2 +- example/pubspec.yaml | 2 +- native_workmanager_gen/CHANGELOG.md | 9 +++++ native_workmanager_gen/pubspec.yaml | 2 +- pubspec.yaml | 2 +- 13 files changed, 122 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6f26f..4b02cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.7.0] - 2026-09-10 ### Added @@ -54,6 +54,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 running in the background regardless. Found auditing for bugs similar to #66; verified red-then-green with a device test that reproduces the bug on the pre-fix code before confirming the fix. +- **CI never actually honoured any Flutter version pin.** + `flutter-version-file: .flutter-version` pointed at a plain-text file — + subosito/flutter-action's `flutter-version-file` only parses + `pubspec.yaml`, `.fvmrc`, or `.fvm/fvm_config.json`, so it silently + failed to parse and fell back to the `channel` input's default of + `stable` (non-empty even when the key is omitted from the workflow + yaml), floating every job to whatever Flutter was newest that day. + Switched to `.fvmrc` (the project already manages Flutter locally via + `fvm`) and explicitly empty `channel` as defense in depth. +- `native_workmanager_gen` had no `analysis_options.yaml` of its own, so + `dart analyze` walked up to the root plugin's — which includes + `package:flutter_lints/flutter.yaml`, unresolvable against a pure-Dart + package that only depends on `lints`. That silently broke analysis for + the whole generator package (every run just warned and skipped), + hiding one unused import and two lint issues in its own test suite. + +### Known Issues + +- **`DartWorker.timeoutMs` does not appear to deliver a terminal event to + `NativeWorkManager.events` when it fires — and neither does a plain + `DartWorker`'s own natural completion under the same conditions, + reproduced on both Android and iOS.** Found auditing this release's + stress suite (`issue_30 stress` in `stress_and_system_test.dart`): + isolating a single `DartWorker(timeoutMs: 1000, input: {delayMs: + 2000})` and listening to `NativeWorkManager.events` directly showed + only the `isStarted` event arriving — no terminal event within 15 s on + either platform, well past both the 1 s timeout and the callback's own + 2 s natural completion. Confirmed pre-existing on `main` at v1.6.1 + (unaffected by anything in this release) via a worktree comparison, so + it does not block this release, but a real app awaiting that event + would hang indefinitely. The existing `issue_30 stress` test does not + catch this: it treats "no event arrived" and "correctly failed" as the + same outcome (`catch (_) { actuals.add(0) }`), so 3 of its 4 + timeout-should-fire cases pass by coincidence rather than verifying a + failure event was actually received; only the 4th (`#10`) surfaces at + all, and only as a flaky, timing-order-dependent unhandled-`Future` + error rather than a real assertion failure. Needs its own investigation + — not attempted here. ## [1.6.1] - 2026-09-07 diff --git a/README.md b/README.md index c851e27..ce62bd3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ No boilerplate. No native code to write. No `AndroidManifest.xml` changes. Each ```yaml dependencies: - native_workmanager: ^1.6.1 + native_workmanager: ^1.7.0 ``` **2. Initialize once in `main()`:** diff --git a/doc/ANDROID_SETUP.md b/doc/ANDROID_SETUP.md index 1009ef0..deda4f5 100644 --- a/doc/ANDROID_SETUP.md +++ b/doc/ANDROID_SETUP.md @@ -119,7 +119,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - native_workmanager: ^1.6.1 + native_workmanager: ^1.7.0 ``` Run: diff --git a/doc/GETTING_STARTED.md b/doc/GETTING_STARTED.md index 86eab0c..206648f 100644 --- a/doc/GETTING_STARTED.md +++ b/doc/GETTING_STARTED.md @@ -34,7 +34,7 @@ Or manually: ```yaml dependencies: - native_workmanager: ^1.6.1 + native_workmanager: ^1.7.0 ``` Then run: diff --git a/doc/MIGRATION_GUIDE.md b/doc/MIGRATION_GUIDE.md index 2234777..1570af6 100644 --- a/doc/MIGRATION_GUIDE.md +++ b/doc/MIGRATION_GUIDE.md @@ -152,7 +152,7 @@ dependencies: **After:** ```yaml dependencies: - native_workmanager: ^1.6.1 + native_workmanager: ^1.7.0 ``` **Then run:** @@ -865,7 +865,7 @@ Use this checklist to track your migration progress: ```yaml dependencies: workmanager: ^0.5.0 - native_workmanager: ^1.6.1 + native_workmanager: ^1.7.0 ``` Migrate tasks one at a time, then remove workmanager when done. diff --git a/doc/MIGRATION_TOOL_README.md b/doc/MIGRATION_TOOL_README.md index 8b1065a..5f7466c 100644 --- a/doc/MIGRATION_TOOL_README.md +++ b/doc/MIGRATION_TOOL_README.md @@ -142,7 +142,7 @@ Updated dependencies file: dependencies: flutter: sdk: flutter - native_workmanager: ^1.6.1 # Replaced workmanager + native_workmanager: ^1.7.0 # Replaced workmanager ``` **Usage:** diff --git a/example/lib/main.dart b/example/lib/main.dart index ce96911..c5d7b0d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -108,6 +108,7 @@ void main() async { 'customTask': customTaskCallback, 'heavyTask': heavyTaskCallback, 'longRunningTask': longRunningTaskCallback, + 'cancellableTask': cancellableTaskCallback, 'benchHeavyCompute': benchHeavyComputeCallback, // Stress & System Test Workers 'stress_worker': stressWorkerCallback, @@ -152,6 +153,30 @@ Future longRunningTaskCallback(Map? input) async { return true; } +/// Issue #66 demo callback: loops in 500ms chunks, polling +/// `NativeWorkManager.isTaskCancelled` between each one and bailing out as +/// soon as it turns true. Used by the "Cooperative Cancellation (Issue #66)" +/// demo card — enqueues this, then cancels it shortly after it starts, to +/// prove the poll actually observes the cancellation instead of running the +/// full 20 s to completion. +@pragma('vm:entry-point') +Future cancellableTaskCallback(Map? input) async { + final taskId = input?['__taskId'] as String?; + debugPrint('📱 Cancellable Dart Worker: starting (taskId=$taskId)'); + for (var chunk = 1; chunk <= 40; chunk++) { + if (taskId != null && await NativeWorkManager.isTaskCancelled(taskId)) { + debugPrint( + '📱 Cancellable Dart Worker: saw cancellation at chunk ' + '$chunk/40 — bailing out', + ); + return false; + } + await Future.delayed(const Duration(milliseconds: 500)); + } + debugPrint('📱 Cancellable Dart Worker: ran to completion, never cancelled'); + return true; +} + /// Heavy task callback (for isHeavyTask demo). @pragma('vm:entry-point') Future heavyTaskCallback(Map? input) async { diff --git a/example/lib/pages/comprehensive_demo_page.dart b/example/lib/pages/comprehensive_demo_page.dart index b1bad29..02aa5fc 100644 --- a/example/lib/pages/comprehensive_demo_page.dart +++ b/example/lib/pages/comprehensive_demo_page.dart @@ -1396,6 +1396,45 @@ DartWorker( }, ), + _DemoCard( + title: '1c. Dart Worker — Cooperative Cancellation (Issue #66)', + description: + 'Schedules a 20 s callback that polls isTaskCancelled every ' + '500 ms, then cancels it 2 s in. Dart has no API to ' + 'preemptively abort a running Future, so this only works ' + 'because the callback checks in cooperatively — watch logs ' + 'for "saw cancellation" well before the 20 s mark.', + icon: Icons.cancel_outlined, + code: ''' +'cancellableTask': (input) async { + final taskId = input?['__taskId'] as String?; + for (var i = 1; i <= 40; i++) { + if (taskId != null && + await NativeWorkManager.isTaskCancelled(taskId)) { + return false; // bail out — do not keep working + } + await Future.delayed(const Duration(milliseconds: 500)); + } + return true; +}''', + onRun: () async { + final taskId = + 'issue-66-cancel-${DateTime.now().millisecondsSinceEpoch}'; + await NativeWorkManager.enqueue( + taskId: taskId, + trigger: TaskTrigger.oneTime(), + worker: DartWorker(callbackId: 'cancellableTask'), + ); + onResult( + '🎯 Issue #66 demo: scheduled a 20 s callback, cancelling it ' + 'in 2 s. Watch logs for "saw cancellation" well short of 20 s.', + ); + Future.delayed(const Duration(seconds: 2), () { + unawaited(NativeWorkManager.cancel(taskId: taskId)); + }); + }, + ), + _DemoCard( title: '2. Custom Native Worker (Kotlin)', description: diff --git a/example/pubspec.lock b/example/pubspec.lock index b3136f8..02d8ec9 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -261,7 +261,7 @@ packages: path: ".." relative: true source: path - version: "1.6.1" + version: "1.7.0" objective_c: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 577167e..043ef12 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: native_workmanager_example description: "Demonstrates how to use the native_workmanager plugin." -version: 1.6.1+1 +version: 1.7.0+1 # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev diff --git a/native_workmanager_gen/CHANGELOG.md b/native_workmanager_gen/CHANGELOG.md index 14c039f..812f8d5 100644 --- a/native_workmanager_gen/CHANGELOG.md +++ b/native_workmanager_gen/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.7.0] - 2026-09-10 + +- Version bump synchronized with `native_workmanager` 1.7.0 + (`NativeWorkManager.isTaskCancelled` cooperative cancellation, issue #66). + No codegen changes — also fixed this package's own `analysis_options.yaml` + gap (it had none, so `dart analyze` was silently resolving against the + root plugin's Flutter-lints config instead of this package's own `lints` + dependency) and two lint issues that gap had been hiding. + ## [1.6.1] - 2026-09-07 - Version bump synchronized with `native_workmanager` 1.6.1 (Android completion-event regression diff --git a/native_workmanager_gen/pubspec.yaml b/native_workmanager_gen/pubspec.yaml index 63aaee3..32adb37 100644 --- a/native_workmanager_gen/pubspec.yaml +++ b/native_workmanager_gen/pubspec.yaml @@ -1,5 +1,5 @@ name: native_workmanager_gen -version: 1.6.1 +version: 1.7.0 description: > Code generator for native_workmanager. Generates type-safe DartWorker callback IDs and worker registry from diff --git a/pubspec.yaml b/pubspec.yaml index b6deb9e..362712e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: native_workmanager description: "Background task scheduling for Flutter — 25+ native workers (HTTP, image, crypto, file), task chains, zero Flutter Engine overhead." -version: 1.6.1 +version: 1.7.0 homepage: https://github.com/brewkits/native_workmanager repository: https://github.com/brewkits/native_workmanager issue_tracker: https://github.com/brewkits/native_workmanager/issues From c53c61ad5e083ba30634d06ce0bf66caf9669164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 10 Sep 2026 07:51:09 +0700 Subject: [PATCH 9/9] docs(changelog): correct overstated Known Issues claim about timeoutMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 12-case stress-test log shows a perfect correlation: every case where the callback finished naturally (no timeout race) delivered its terminal event; only the cases where timeoutMs actually fired dropped it. The original wording claimed natural completion could be affected too — that's not what the evidence shows and overstates the blast radius. Narrowed to what's actually demonstrated: the timeout-fires path drops both the immediate failure event and the late result from the abandoned invocation. --- CHANGELOG.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b02cc1..91c1c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,26 +72,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Known Issues -- **`DartWorker.timeoutMs` does not appear to deliver a terminal event to - `NativeWorkManager.events` when it fires — and neither does a plain - `DartWorker`'s own natural completion under the same conditions, - reproduced on both Android and iOS.** Found auditing this release's - stress suite (`issue_30 stress` in `stress_and_system_test.dart`): - isolating a single `DartWorker(timeoutMs: 1000, input: {delayMs: - 2000})` and listening to `NativeWorkManager.events` directly showed - only the `isStarted` event arriving — no terminal event within 15 s on - either platform, well past both the 1 s timeout and the callback's own - 2 s natural completion. Confirmed pre-existing on `main` at v1.6.1 - (unaffected by anything in this release) via a worktree comparison, so - it does not block this release, but a real app awaiting that event - would hang indefinitely. The existing `issue_30 stress` test does not - catch this: it treats "no event arrived" and "correctly failed" as the - same outcome (`catch (_) { actuals.add(0) }`), so 3 of its 4 - timeout-should-fire cases pass by coincidence rather than verifying a - failure event was actually received; only the 4th (`#10`) surfaces at - all, and only as a flaky, timing-order-dependent unhandled-`Future` - error rather than a real assertion failure. Needs its own investigation - — not attempted here. +- **When `DartWorker.timeoutMs` fires, no terminal event reaches + `NativeWorkManager.events` — and the late result from the abandoned + callback, once it does finish, is dropped too — reproduced on both + Android and iOS.** Found auditing this release's stress suite + (`issue_30 stress` in `stress_and_system_test.dart`): its 12-case matrix + shows a perfect correlation — every case where `delayMs < timeoutMs` + (natural completion, no timeout race) delivered its terminal event; + every case where `timeoutMs` was reached first delivered nothing, ever. + Isolating a single `DartWorker(timeoutMs: 1000, input: {delayMs: 2000})` + confirmed it directly: only the `isStarted` event arrived in a 15 s + window on either platform — nothing at the 1 s timeout mark, and + nothing when the callback's own 2 s delay separately elapsed and it + returned a real result to an invocation nobody was listening for + anymore. Worker completions with **no** timeout race are not implicated + by this — only the timeout-fires case. Confirmed pre-existing on `main` + at v1.6.1 (unaffected by anything in this release) via a worktree + comparison, so it does not block this release, but a real app awaiting + that event on a task that times out would hang indefinitely. The + existing `issue_30 stress` test does not catch this: it treats "no + event arrived" and "correctly failed" as the same outcome (`catch (_) { + actuals.add(0) }`), so 3 of its 4 timeout-should-fire cases pass by + coincidence rather than verifying a failure event was actually + received; only the 4th (`#10`) surfaces at all, and only as a flaky, + timing-order-dependent unhandled-`Future` error rather than a real + assertion failure. Needs its own investigation — not attempted here. ## [1.6.1] - 2026-09-07