fix: deadlocks between kernel close, the event loop and reactive updates - #1202
Open
maartenbreddels wants to merge 1 commit into
Open
fix: deadlocks between kernel close, the event loop and reactive updates#1202maartenbreddels wants to merge 1 commit into
maartenbreddels wants to merge 1 commit into
Conversation
Three lock cycles, all reproduced before the fix: - context.close() ran on the uvicorn event loop (HTTP evict route, lifespan shutdown). The kernel's message thread holds context.lock for a whole event handler and needs the loop for every widget update (portal.call), while close() waits for that lock: a permanent hang for evict, and a dead loop (failing health checks) for the whole handler duration at shutdown. Both now run off the loop, and shutdown closes all kernels in parallel under a deadline so one long handler cannot push it past the orchestrator's grace. - The cull ran close() inline on the shared keep-alive loop, so one kernel wedged in a handler stalled the cull of every other kernel: a process-wide leak. The cull now closes on its own thread. - update() and field sets fired listeners while holding the store lock. A listener renders, which takes reacton's render lock; the render thread setting (or, with mutation detection, reading) the same reactive needs the store lock: ABBA between a task thread and the render thread. Stores now separate storing (_set_deferred, under the lock) from firing (after). Also: Thread.start() could hang forever when the kernel closed between Thread() and start() (the patched bootstrap raised before _started was set), close() could abort on a KeyError before closed_event was set, and send_bytes from the loop thread sent twice. Found in review and also fixed: in non-threaded mode a close() from a plain thread (cull, evict, shutdown) raised on the unset context-id ContextVar before closed_event was set; and a reconnect could mark a page connected on a kernel the cull had just decided to close (the CLOSING mark is now set under the same lock hold as the decision; docs/tla/SolaraLifecycle.tla shows the interleaving). docs/deadlock-rules.md lists the locks and the rules; tests/unit/deadlock_test.py pins each cycle with a deterministic two-thread test; docs/tla/ holds the PlusCal models that re-find the cycles under the old rules and pass under the new ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Audit of releases 1.58-1.61 for deadlocks and leaks. Three lock cycles, all reproduced live before the fix:
context.close()ran on the uvicorn event loop (evict route, lifespan shutdown) while the kernel's message thread holdscontext.lockfor a whole handler and needs the loop for every widget update: evict hung the server permanently, shutdown with--timeout-graceful-shutdownleft the loop dead for the handler's duration. Both now run off the loop; shutdown closes all kernels in parallel under a deadline (SOLARA_SERVER_SHUTDOWN_CLOSE_TIMEOUT, default 10s, daemon threads because the interpreter joins executor workers at exit).close()inline on the shared keep-alive loop: one kernel wedged in a handler stalled the cull of every other kernel (a process-wide leak). It now closes on its own thread.update()and field sets fired listeners under the store lock; listeners render, reacton holds the render lock for the whole pass, and the render thread can need the store lock (with mutation detection even on a read). Stores now separate storing (_set_deferred, under the lock) from firing (after).Found in review and also fixed:
Thread.start()hang when the kernel closes betweenThread()andstart();close()aborting on a KeyError beforeclosed_event;send_bytesfrom the loop thread sending twice; in non-threaded modeclose()from a plain thread raising on the unset context-id ContextVar; a reconnect marking a page connected on a kernel the cull had just decided to close (CLOSING is now set under the decision's lock hold).Known trade-off, documented: two threads writing the same reactive at once may notify out of store order (plain
.value = xnever ordered either; every solara listener re-reads the store).Docs and tests
docs/deadlock-rules.md: the lock inventory and six rules.tests/unit/deadlock_test.py: deterministic two-thread cycles for each rule, all red before the fix.tests/integration/deadlock_test.py: evict while a handler is busy (red with the inline close, green after).docs/tla/: PlusCal models; the lock model re-finds the three cycles under the old rules, the lifecycle model found the cull/reconnect race and validated its fix.Verification
Full unit suite green. Leak integration tests green; 10x10 memory harness on the kitchen-sink app shows no leak and a flat thread count. Live: evict returns while the loop keeps answering
/readyz; SIGTERM with a 60s handler exits in 13.6s. Remaining integration failures inserver_test.pyandtest_reconnect_failare pre-existing on master (verified on a clean checkout).🤖 Generated with Claude Code