Deep-dive: binding the socket in ~1 second — and the shutdown race hiding inside deferred startup #1553
debpalash
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Part of a series of engineering deep-dives from VoiceStudio's reliability work. This one covers the startup restructure that binds the socket in ~1 second — and the shutdown race hiding inside it.
Why restructure startup at all
VoiceStudio's backend drags in torch, torchaudio, and ~30 API routers at import time. On a cold disk that meant the socket didn't even bind for tens of seconds — and to the desktop shell (and the user), "socket not bound" is indistinguishable from "backend dead". Roughly 1 in 5 of every issue ever filed against the app was some flavor of can't reach the backend / backend died.
The fix (#1550) splits startup:
While that runs, a
StartupGateMiddleware503s everything except/healthand/startup/progress, and/healthanswers 503 with the current step name — so the shell can narrate "Loading ML runtime…" instead of showing a dead spinner, and a startup that dies tells you which step killed it.The race: cancelling a thread that hasn't started
Here's the subtle part. Shutdown must not let interpreter teardown race the import work still running on the executor thread (that's our #1000 bug class — torch imports dying mid-teardown with bizarre errors). So shutdown joins the thread, bounded:
Three ways this innocent-looking join can deadlock or get skipped, and what closes each:
1. The queued-but-not-running callable. If shutdown samples
_phase_a_startedwhile the executor callable is still queued, the flag is unset, the join is skipped, and teardown races the imports anyway. Fix: set the flag before submission, not inside the callable:2. The cancelled queued callable. Now the opposite hazard: with the flag set before submission, a task cancellation landing while the callable is still queued would (unshielded) cancel the inner future — the callable never runs,
_phase_a_finishedis never set, and shutdown stalls on the full 20s timeout every time.asyncio.shieldmeans the build always runs to completion and always sets the event; the cancellation still propagates to the startup task itself.3. The build that exits without reporting. Every exit path of the build — including the idempotent already-built early return — must set the event, or a shutdown that observed
startedcan wait on an event nothing will set:An AST-level regression test pins all three properties (flag-before-submit, shield present, finally-sets-finished) so a refactor can't quietly reintroduce any of them.
One more detail: dying loudly, but not instantly
If Phase A fails, the backend should die with import-crash semantics (traceback to stderr, hard exit, crash marker attribution). But the whole point of the progress ledger is that the shell's last poll should capture which step failed — so the failure path marks the step failed, prints the traceback, then waits one beat asynchronously before
os._exit(1):(An earlier revision used
time.sleepthere. A reviewer caught it: a blocking sleep freezes the event loop, making the "one last poll" beat serve nothing.)Results
/startup/progress.PR: #1550. The fault-injection harness that exercises the death paths end-to-end: #1551.
All reactions