Fix UI freezes and snapshot deadlocks in Tao application scope - #430
Fix UI freezes and snapshot deadlocks in Tao application scope#430DatL4g wants to merge 2 commits into
Conversation
|
@DatL4g Thanks for digging into this. The freeze is real — I reproduce it on I can't take the patch as-is though. I measured the three changes separately on macOS: one is a genuine fix, one is a no-op whose companion edit drops notifications, and the 1.
|
| app-level recompositions after the crash | outcome | |
|---|---|---|
Job() (main) |
scope cancelled → finally → composition.dispose() + app.exit() |
exit code 0, stack trace printed |
SupervisorJob() (PR) |
0, permanently — while windows keep rendering | still alive at 8 s, killed by my watchdog |
Recomposer shuts itself down when its effectJob completes, so isolating the failure doesn't save recomposition — it only removes the teardown. exitApplication() still resolves (the snapshot manager survives as a sibling), so the app stays closable, but the application composition is dead for the rest of the process lifetime. Compose Desktop's awaitApplication propagates for the same reason.
2. Channel(1) → Channel.CONFLATED — no-op, and the companion edit loses notifications
The sent gate already guarantees at most one token in flight, so the channel is never full: over 4 writes, refused trySend = 0 with both capacities.
What does change behaviour is moving sent.set(false) into the trailing finally. Reopening the gate before the call is what lets a write landing during the notification round re-arm the channel; with the reset moved after, that write finds the gate closed and its token is dropped — 2 apply rounds become 1, second change never delivered. Masked in a real tao app, because TaoMainDispatcher.pump() fires its own sendApplyNotifications() after any drain that ran blocks (dispatch/TaoMainDispatcher.kt:259 and :309) — 20/20 isolated background writes landed. Latent, not visible, but no upside to trade for it.
3. The try/catch — this one is a real fix
With an apply observer that throws once: no catch → 1 of 4 notifications delivered, then nothing ever again (collector dead). With the catch → 4, collector alive. A dead collector means no state write from any thread is forwarded to the main thread again, app-wide.
Two amendments before I merge it:
- log it —
t.printStackTrace(), same asTaoMainDispatcher.pump()does a few lines away. Swallowing silently means we'd never see the anomaly that motivated this PR. - rethrow
CancellationException, otherwise scope cancellation stops being cooperative.Throwablealso swallowsOutOfMemoryError/StackOverflowErrormid-apply.
What I'd like
Reduce the PR to change 3 (logging + CancellationException rethrown), keeping Job(), Channel<Unit>(1) and sent.set(false) before the call.
The freeze you actually hit is one level down: the scene's recomposer dying on an unhandled effect exception, with nothing reporting it and nothing disposing the window. That belongs in TaoComposeSceneHost, not in the application job — I'll open a separate issue.
If the build is what blocked you, ./gradlew publishDevToMavenLocal --no-configuration-cache is usually enough to iterate. I can push the probes behind these numbers if you want to reproduce them.
Technical Context & Motivation:
SupervisorJob()vs.Job()When the root scope was initialized with a standard
Job(), coroutine cancellation propagated bi-directionally. If any child coroutine launched in this scope (e.g., a failingLaunchedEffect, a Coil image decoding error, or a Skiko native crash) threw an unhandled exception, the exception bubbled up and cancelled the entire parentJob. This killed therecomposerand the snapshot manager, leaving the UI permanently frozen but the JVM process alive.SupervisorJobisolates child failures, ensuring one crashing UI component does not bring down the entire Compose ecosystem.Channel.CONFLATEDThe snapshot manager previously used a standard buffered
Channel<Unit>(1). During rapid state changes (e.g., dragging a window or running animations), multiple state writes could attempt to send aUnittoken into a full channel. A standard channel rejects these inputs or suspends, which resulted in missed snapshot apply notifications and deadlocked UI states.Channel.CONFLATEDhas a buffer of 1 but uses aDROP_OLDESToverflow strategy. It guarantees that the channel never backs up, and incoming state-write tokens simply overwrite the old ones, ensuring the Flow collector always wakes up for the next frame.Snapshot Collector Exception Handling
The snapshot manager reads from the channel and executes
Snapshot.sendApplyNotifications(). Previously, if this internal Compose function threw a runtime exception, thefinallyblock successfully unlocked theAtomicBoolean, but the exception escaped and permanently killed the flow collector coroutine. Subsequent state writes would successfully send tokens into the channel, but no collector was left alive to read them. By swallowing exceptions inside thecollectblock, the loop survives anomalies and continues processing subsequent frames.Testing
I could not properly test it because I am unable to build the whole project on my machine.
However the code is mostly the same as before just preventing recomposition cancellations and missed updates.