Skip to content

fix: deadlocks between kernel close, the event loop and reactive updates - #1202

Open
maartenbreddels wants to merge 1 commit into
masterfrom
fix/deadlocks-close-off-loop
Open

fix: deadlocks between kernel close, the event loop and reactive updates#1202
maartenbreddels wants to merge 1 commit into
masterfrom
fix/deadlocks-close-off-loop

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

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 holds context.lock for a whole handler and needs the loop for every widget update: evict hung the server permanently, shutdown with --timeout-graceful-shutdown left 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).
  • The cull ran 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 between Thread() and start(); close() aborting on a KeyError before closed_event; send_bytes from the loop thread sending twice; in non-threaded mode close() 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 = x never 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 in server_test.py and test_reconnect_fail are pre-existing on master (verified on a clean checkout).

🤖 Generated with Claude Code

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>
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.

1 participant