Skip to content

Fix UI freezes and snapshot deadlocks in Tao application scope - #430

Draft
DatL4g wants to merge 2 commits into
NucleusFramework:mainfrom
DatL4g:main
Draft

Fix UI freezes and snapshot deadlocks in Tao application scope#430
DatL4g wants to merge 2 commits into
NucleusFramework:mainfrom
DatL4g:main

Conversation

@DatL4g

@DatL4g DatL4g commented Aug 3, 2026

Copy link
Copy Markdown

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 failing LaunchedEffect, a Coil image decoding error, or a Skiko native crash) threw an unhandled exception, the exception bubbled up and cancelled the entire parent Job. This killed the recomposer and the snapshot manager, leaving the UI permanently frozen but the JVM process alive. SupervisorJob isolates child failures, ensuring one crashing UI component does not bring down the entire Compose ecosystem.

Channel.CONFLATED

The 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 a Unit token into a full channel. A standard channel rejects these inputs or suspends, which resulted in missed snapshot apply notifications and deadlocked UI states. Channel.CONFLATED has a buffer of 1 but uses a DROP_OLDEST overflow 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, the finally block successfully unlocked the AtomicBoolean, 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 the collect block, 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.

@kdroidFilter

Copy link
Copy Markdown
Collaborator

@DatL4g Thanks for digging into this. The freeze is real — I reproduce it on main: an unhandled exception out of a LaunchedEffect leaves the window on screen with 0 recompositions and the process alive.

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 SupervisorJob swap makes the symptom you describe worse.

1. Job()SupervisorJob() — this is what produces "frozen but alive"

Window-content effects never reach the application scope's Job. DecoratedWindow.kt:383 builds TaoComposeSceneHost without passing coroutineContext, so the scene's ComposeScene gets EmptyCoroutineContext (scene/TaoComposeSceneHost.kt:93) and its recomposer has no parent job. Crashing a LaunchedEffect inside a DecoratedWindow gives 0 recompositions and a live process either way — identical.

The swap only bites for an effect declared at application level, where the recomposer's effectJob really is a child of the scope job:

app-level recompositions after the crash outcome
Job() (main) scope cancelled → finallycomposition.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 itt.printStackTrace(), same as TaoMainDispatcher.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. Throwable also swallows OutOfMemoryError / StackOverflowError mid-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.

@kdroidFilter
kdroidFilter marked this pull request as draft August 5, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants