fix: contain uncaught coroutine exception in AppLifecycleObserver foreground sync (#566) - #569
Conversation
…eground sync (#566) AppLifecycleObserver (App.kt) launched syncTriggerManager.onAppForeground() in an unguarded scope.launch using rememberCoroutineScope() (plain Job, no SupervisorJob/CoroutineExceptionHandler). A non-CancellationException throwable from the foreground sync chain (refreshPremiumStatusFromServer / attemptSync -> Ktor/IO) propagated to propagateExceptionFinalResort -> processUnhandledException -> terminateWithUnhandledException -> abort(), killing the iOS-on-mac process with SIGABRT after wake (TestFlight 0.9.1 / 20260607234). Changes: - App.kt: wrap the scope.launch body in try/catch (rethrow CancellationException, log+swallow others via Logger.e). This is the crash-prevention boundary. - SyncTriggerManager.kt: wrap onAppForeground() body in try/catch (rethrow CancellationException, log via Logger.e and record via onSyncFailure) so foreground sync failures are recorded in RetryState for backoff/retry UI. - AppLifecycleCoroutineContainmentTest: pins the containment pattern at the coroutine-scope level (non-Cancellation throwable does not reach CoroutineExceptionHandler; CancellationException is rethrown and cancels job). - SyncTriggerManagerTest: regression tests that a raw throwable from refreshPremiumStatusFromServer() is recorded in RetryState and does not propagate, and that CancellationException is rethrown (not recorded). RCA: GPT-5.5 xhigh (issue #566 comment 4734443850). Fixes #566
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Code Review
This pull request addresses a crash issue (#566) caused by uncaught coroutine exceptions during foreground sync. It introduces exception containment in both AppLifecycleObserver and SyncTriggerManager.onAppForeground() to catch and log general failures while rethrowing CancellationException to preserve coroutine cancellation semantics. Additionally, comprehensive regression tests have been added. The review feedback suggests several key improvements: removing the redundant outer try-catch block in App.kt, catching Exception instead of Throwable in SyncTriggerManager.kt to avoid swallowing critical JVM system errors, updating the test double to match, and deleting the redundant AppLifecycleCoroutineContainmentTest.kt file once the outer try-catch is removed.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| try { | ||
| syncTriggerManager.onAppForeground() | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Throwable) { | ||
| Logger.e(e) { "AppLifecycleObserver: onAppForeground failed" } | ||
| } |
There was a problem hiding this comment.
The try-catch block in AppLifecycleObserver is redundant because syncTriggerManager.onAppForeground() is already fully wrapped in its own try-catch block that catches all non-cancellation exceptions, logs them, and handles the failure gracefully.
Additionally, keeping this outer try-catch introduces a maintainability risk: if any other code is added to this launch block in the future, any exceptions thrown by it would be caught here and logged with the misleading message "AppLifecycleObserver: onAppForeground failed".
We should simplify this by removing the redundant try-catch wrapper and directly calling syncTriggerManager.onAppForeground().
syncTriggerManager.onAppForeground()| attemptSync(bypassThrottle = false) | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Throwable) { |
There was a problem hiding this comment.
Catching Throwable is generally discouraged because it catches critical JVM system errors (such as OutOfMemoryError, StackOverflowError, or NoClassDefFoundError) which should not be swallowed or handled as transient sync failures. Swallowing these can leave the application in an unstable or corrupted state.
It is safer and more idiomatic to catch Exception instead, which covers all standard network, database, and serialization exceptions while allowing critical system errors to propagate.
| } catch (e: Throwable) { | |
| } catch (e: Exception) { |
| attemptSync(bypassThrottle = false) | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Throwable) { |
| * rememberCoroutineScope()'s plain Job and to make CoroutineExceptionHandler | ||
| * observable for root coroutines (handlers are ignored for child coroutines). | ||
| */ | ||
| class AppLifecycleCoroutineContainmentTest { |
There was a problem hiding this comment.
If the redundant try-catch wrapper in App.kt is removed (as suggested in App.kt), this entire test class becomes redundant. The exception containment and cancellation propagation are already thoroughly tested in SyncTriggerManagerTest.kt (e.g., onAppForegroundRecordsFailureAndDoesNotPropagateWhenPremiumRefreshThrows and onAppForegroundRethrowsCancellationException). You can safely delete this file to keep the test suite clean and focused.
Summary
Fixes the iOS-on-mac TestFlight 0.9.1
SIGABRTcrash (issue #566) where an uncaught Kotlin coroutine exception from the foreground sync chain reachedpropagateExceptionFinalResort→processUnhandledException→abort()after wake.Root cause
AppLifecycleObserver(App.kt) launchessyncTriggerManager.onAppForeground()insidescope.launch { ... }usingrememberCoroutineScope()— a plainJobwith noSupervisorJoband noCoroutineExceptionHandler. Neither the launch body noronAppForeground()had a try/catch, so any non-CancellationExceptionthrowable from the foreground sync chain (refreshPremiumStatusFromServer()/attemptSync()→ Ktor/IO) propagated to the Kotlin/Native final-resort exception handler and aborted the process. This deviates from the codebase's establishedSupervisorJobcontainment pattern used byPortalAuthRepository,MigrationManager,KableBleRepository, etc.RCA: GPT-5.5 xhigh — #566 (comment)
Changes
Primary fix — crash-prevention boundary (
App.kt)Wrap the
scope.launchbody in try/catch: rethrowCancellationException(preserve cancellation semantics), log + swallow all other throwables viaLogger.e. This guarantees a foreground-sync throwable can never reachpropagateExceptionFinalResort.Defensive secondary fix (
SyncTriggerManager.kt)Wrap
onAppForeground()body in try/catch: rethrowCancellationException, log viaLogger.eand record the failure viaonSyncFailure(e)so foreground sync failures are reflected inRetryState(enabling backoff/retry UI) rather than being silently swallowed by the outer catch.Regression tests
AppLifecycleCoroutineContainmentTest(new): pins the containment pattern at the coroutine-scope level — a non-CancellationExceptionthrowable does not reach theCoroutineExceptionHandler(would not abort), andCancellationExceptionis rethrown so the launched job is cancelled.SyncTriggerManagerTest(extended): a raw throwable fromrefreshPremiumStatusFromServer()(the real crash path) is recorded inRetryStateand does not propagate;CancellationExceptionis rethrown and not recorded.Test evidence
./gradlew :shared:testAndroidHostTest(JVM host unit tests, runscommonTest):AppLifecycleCoroutineContainmentTestSyncTriggerManagerTestSyncBackoffTestSyncFailureCapTestAll 35 tests pass, including the 4 new regression tests:
nonCancellationThrowableDoesNotReachUncaughtHandlercancellationExceptionIsRethrownAndCancelsJobonAppForegroundRecordsFailureAndDoesNotPropagateWhenPremiumRefreshThrowsonAppForegroundRethrowsCancellationExceptionAcceptance criteria
CancellationExceptionthrowable fromonAppForeground()or any transitive call does not abort the process.Logger.ewith call-site context.CancellationExceptionis rethrown — cancellation semantics preserved.RetryState(secondary fix).:shared:testAndroidHostTest).main.Non-goals (per RCA)
rememberCoroutineScope()is retained (Compose lifecycle-tied); the try/catch approach is lower-risk than swapping in a customSupervisorJobscope.Fixes #566