fix: respawn panicked solver workers instead of losing them - #348
fix: respawn panicked solver workers instead of losing them#348zizou0x wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
The respawn direction is good and the shutdown buffering is race-free. Three things to address before merge:
- Worker threads now hold a shutdown sender clone, so dropping the pool without an explicit shutdown no longer stops the workers. Use resubscribe instead.
- The retry loop respawns forever. Add exponential backoff with a bounded attempt budget that resets on stable sessions, and kill the process when any worker gives up (workers are homogeneous, so a budget-exhausting failure is almost certainly shared; exiting at the first give-up avoids a permanently degraded pool).
- A respawned worker starts not-ready behind a green health check. Seed its readiness tracker from the shared derived data.
Inline comments carry the details, plus test and doc fixes. Note the branch now conflicts with main: spawn_workers_generic gained liquidity_scope, exclude_protocols and fallback_fee_tiers, which must move into the session loop on rebase.
15 comments — 0 Blocker · 4 Important · 3 Suggestion · 6 Nit · 2 Context.
Important
- IMPL
#1Worker threads hold a shutdown sender clone, so the shutdown channel can never close —registry.rs:170 - TESTS
#1Poison-task result is discarded, so the test can pass without a panic —registry.rs:512 - TESTS
#2No test for the buffered-shutdown path (shutdown sent mid-respawn) —registry.rs:190 - DOCS
#1spawn_workers_genericdoc contradicts the new respawn behavior —registry.rs:139
Suggestions
- DESIGN
#2Session/supervision loop hidden in a 75-line inline closure —registry.rs:178 - DESIGN
#3Fixed 100ms backoff allows a permanent 10 Hz error-log/rebuild loop —registry.rs:42 - DOCS
#2"(or poisoned)" invents a tokio semantics that does not exist —registry.rs:184
Nits
- DESIGN
#4Third bespoke mockAlgorithmimpl in tests —registry.rs:449 - TESTS
#4POISON_AMOUNT as u128cast —registry.rs:514 - DOCS
#3Loop-top comment narrates the fixed bug and is verbose —registry.rs:179 - DOCS
#4sessionbinding holds the session's result, not the session —registry.rs:198 - DOCS
#6Test doc says the panic "does not kill the worker" —registry.rs:449 - DOCS
#7Non-parallel names for the two test tasks —registry.rs:513
Context
- BIZ
#2Respawned worker answers NotReady (after waiting the full algorithm timeout) until the next derived-data event —registry.rs:198 - BIZ
#3Deterministic startup panics now retry forever instead of dying once —registry.rs:183
[DOCS #1 · Important]
Description: The doc says "The factory closure is called once per worker to create the algorithm instance" (line 146), but the loop now calls factory once per session — again after every panic respawn (registry.rs:205). Factory implementors must now tolerate repeated calls; the doc states the opposite contract, and the bullet list omits the panic-containment behavior that is now the function's most important guarantee.
Suggestion: Update the doc: state that each worker thread runs sessions in a loop, a panic respawns the worker after a backoff, and the factory is called at every (re)start. Remove or correct "called once per worker".
A panic while solving previously unwound through the worker thread and killed it silently; the pool only noticed at shutdown join(). Once every worker had hit a poison request, the pool answered 100% no_route until the pod was recreated. Each worker thread now runs sessions in a catch_unwind loop: a panic is logged at ERROR with the panic message, counted in the worker_pool_worker_panics_total metric, and the worker is respawned after a short backoff. Clean shutdowns still exit the thread, including shutdown signals arriving while a worker is mid-respawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260afbe to
b20231f
Compare
Problem
Prod incident 2026-07-23. A quote request caused a divide-by-zero panic in pool math. Each occurrence killed
one solver worker thread. The panic was logged only at shutdown
join(), about 100 minutes later. After allworkers on both ethereum pods died, every quote returned
no_route_foundfor about 1.5 hours, until the podswere recreated.
Root cause
spawn_workers_genericranSolverWorker::rundirectly in the thread body. A panic unwound the thread andended it. The pool kept the
JoinHandle, so it reported the full worker count and stayed silent. Nothinglogged the loss until shutdown.
Fix
Each worker thread now runs worker sessions in a loop, in
WorkerContext::run_sessions. A session is wrappedin
catch_unwind.spawn_workers_genericonly spawns threads.worker_pool_worker_panics_total{pool}, and respawns the worker after a backoff.session that lives 600 s or more resets the budget.
budget-exhausting failure is almost certainly shared. Exactly one thread runs the exit.
allow_stalereadiness from the shared derived-data store, so a respawned workerdoes not fail tasks
NotReadyuntil the next broadcast event.resubscribe(), so workers exit when the pool drops its sender. Apre-check on the long-lived receiver catches a shutdown sent while no session was listening.
Complements #347, which stops that specific panic escaping the simulation call.
Notes for reviewers
Read
supervisor.rsfirst, then the exit policy inpool.rs— that is the one product decision here. Readthe last commit separately: it is a pure test refactor that folds three mock algorithms into one stub, and it
accounts for most of the diff size.
Two accepted trade-offs:
own. A worker that panics about every 9 minutes still exits the process after about 90 minutes. Deliberate:
the give-up path targets deterministic panics, which burn the budget in about 11 seconds.
worker_pool_worker_panics_totalunder-reports the give-up case, because ~11 s of crash loop rarely spans ascrape. Alert on restart count and the give-up log line.
Follow-ups
catch_unwindinsideSolverWorker::run, so a panicking task answers its requester instead ofdropping the oneshot.
sim_guard.rsandsupervisor.rs.num_workers()reports the static count and would over-report if a workerever gives up without exiting the process.
🤖 Generated with Claude Code