Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Unit>,
timeoutMs: Long = DART_READY_TIMEOUT_MS
) {
withTimeoutOrNull(timeoutMs) { ready.await() }
?: throw DartEngineStartupTimeoutException(timeoutMs)
}

/**
* Ensure Flutter engine is initialized.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Original file line number Diff line number Diff line change
@@ -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<Unit>()
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<Unit>()
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<Unit>()
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)
}
}
Loading