From e13afa282e4d11b49fa668f7fb7ec2891cb212e7 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: Mon, 9 Nov 2026 21:29:03 +0700 Subject: [PATCH] fix(android): engine startup timeout is a failure, not a cancellation (#95) ensureEngineInitialized waited for dartReady with withTimeout(10s). The resulting TimeoutCancellationException is a CancellationException, which executeDartCallbackInternal, DartCallbackWorker and ForegroundNativeWorker all rethrow on purpose, so WorkManager saw a cancelled run (isStopped=false, stopReason=-256) instead of a failure. Now awaitDartReady throws DartEngineStartupTimeoutException (plain IllegalStateException) via withTimeoutOrNull, so the normal failure/retry path runs. Real cancellation still propagates. Tests: EngineStartupTimeoutTest (3, JVM). No device test: nothing can force the 10s timer on a device without a test hook, and no device was connected. --- CHANGELOG.md | 12 ++++ .../engine/FlutterEngineManager.kt | 33 +++++++++-- .../engine/EngineStartupTimeoutTest.kt | 57 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 android/src/test/kotlin/dev/brewkits/native_workmanager/engine/EngineStartupTimeoutTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a64d25..beb8d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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] + +### Fixed +- **Issue #95 — Android Dart worker "cancelled" during engine start-up:** when the Flutter + engine's Dart isolate did not report ready within 10 s, the timeout surfaced as a + `TimeoutCancellationException` (a `CancellationException`), which every layer rethrows on + purpose. WorkManager then saw a cancelled run (`isStopped=false`, `stopReason=-256`, + `NOT_STOPPED`) rather than a failure, so `maxRetries`/backoff and the failure event never + ran. It is now an ordinary failure (`DartEngineStartupTimeoutException`) that follows the + normal retry path. Genuine cancellation is unchanged. Root cause identified from the + `cancelled … phase=worker-execution isStopped=false stopReason=-256` log added in 1.10.1. + ## [1.10.1] - 2026-11-09 Android fix for #95, plus a small iOS limiter hardening. Backwards compatible. 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 7c660f7..93ae453 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -57,6 +58,7 @@ object FlutterEngineManager { */ private const val DEFAULT_STOP_HANDLER_BUDGET_MS = 5_000L private const val ENGINE_IDLE_TIMEOUT_MS = 5 * 60 * 1000L // 5 minutes + internal const val DART_READY_TIMEOUT_MS = 10_000L private var engine: FlutterEngine? = null private var methodChannel: MethodChannel? = null @@ -488,6 +490,23 @@ object FlutterEngineManager { } } + /** + * Waits for the Dart isolate's `dartReady` signal. + * + * Throws [DartEngineStartupTimeoutException] (an ordinary exception, NOT a + * CancellationException) when the isolate does not report ready in [timeoutMs], so the + * caller's generic failure path runs (retry/backoff honoured). Uses withTimeoutOrNull so only + * THIS timer can produce the failure: a genuine cancellation of the calling coroutine still + * propagates as a CancellationException. + */ + internal suspend fun awaitDartReady( + ready: CompletableDeferred, + timeoutMs: Long = DART_READY_TIMEOUT_MS + ) { + withTimeoutOrNull(timeoutMs) { ready.await() } + ?: throw DartEngineStartupTimeoutException(timeoutMs) + } + /** * Ensure Flutter engine is initialized. * @@ -634,10 +653,12 @@ object FlutterEngineManager { // Now start the Dart isolate — handler is already registered, no race possible. flutterEngine.dartExecutor.executeDartCallback(dartCallback) - // Wait for Dart to signal it's ready (up to 10 seconds) - withTimeout(10_000L) { - readyDeferred.await() - } + // Wait for Dart to signal it's ready (up to 10 seconds). + // Issue #95: this MUST NOT surface as a TimeoutCancellationException. That is a + // CancellationException, which every layer above deliberately rethrows, so + // WorkManager saw a "cancelled" run (isStopped=false, stopReason=NOT_STOPPED) + // instead of a failure — no retry/backoff, no failure event. + awaitDartReady(readyDeferred) NativeLogger.d("Dart ready signal received") // Store references only after successful initialization @@ -732,3 +753,7 @@ object FlutterEngineManager { return System.currentTimeMillis() - lastUsedTimestamp } } + +/** The Flutter engine started but its Dart isolate never reported ready. Not a cancellation. */ +internal class DartEngineStartupTimeoutException(timeoutMs: Long) : + IllegalStateException("Dart isolate did not signal ready within ${timeoutMs}ms (engine startup timeout)") diff --git a/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/EngineStartupTimeoutTest.kt b/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/EngineStartupTimeoutTest.kt new file mode 100644 index 0000000..e61e9c3 --- /dev/null +++ b/android/src/test/kotlin/dev/brewkits/native_workmanager/engine/EngineStartupTimeoutTest.kt @@ -0,0 +1,57 @@ +package dev.brewkits.native_workmanager.engine + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +/** + * Issue #95: an engine-startup timeout used to surface as TimeoutCancellationException — a + * CancellationException — which every layer above rethrows on purpose, so WorkManager saw a + * cancelled run (isStopped=false, stopReason=NOT_STOPPED) instead of a failure it could retry. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EngineStartupTimeoutTest { + + @Test + fun issue_95_startup_timeout_is_a_failure_not_a_cancellation() = runTest { + val never = CompletableDeferred() + val result = async { + runCatching { FlutterEngineManager.awaitDartReady(never, timeoutMs = 10_000L) } + } + advanceTimeBy(10_001L) + runCurrent() + val error = result.await().exceptionOrNull() + assertTrue("expected a startup timeout, got $error", error is DartEngineStartupTimeoutException) + assertFalse("must not be a CancellationException", error is CancellationException) + assertTrue(error!!.message!!.contains("10000ms")) + } + + @Test + fun issue_95_ready_signal_before_timeout_returns_normally() = runTest { + val ready = CompletableDeferred() + val result = async { FlutterEngineManager.awaitDartReady(ready, timeoutMs = 10_000L) } + advanceTimeBy(5_000L) + ready.complete(Unit) + result.await() + } + + @Test + fun issue_95_real_cancellation_still_propagates_as_cancellation() = runTest { + val never = CompletableDeferred() + val result = async { + runCatching { FlutterEngineManager.awaitDartReady(never, timeoutMs = 10_000L) } + } + runCurrent() + result.cancel() + // The waiting coroutine was cancelled from outside: it must NOT be converted into a + // startup-timeout failure. + val outcome = runCatching { result.await() }.exceptionOrNull() + assertTrue("expected cancellation, got $outcome", outcome is CancellationException) + } +}