fix race condition in sigterm handler - #361
Conversation
|
The handover works on the path where the app task resolves normally, but the let result = handles.app.await?; // JoinError returns here
if handles.sigterm_received.load(Ordering::SeqCst) {
handles.sigterm_done.notified().await;
return Err(eyre::eyre!("Received SIGTERM signal"));
}
On how it's triggered: nothing in Moving the check above the let joined = handles.app.await;
// SIGTERM handover runs even if the app task panicked, so the runtime
// outlives the handler's teardown + savepoint either way.
if handles.sigterm_received.load(Ordering::SeqCst) {
handles.sigterm_done.notified().await;
return Err(eyre::eyre!("Received SIGTERM signal"));
}
let result = joined?;Two smaller notes: The exit-code fallback is stringly-typed. That fallback is also rarely exercised, which is worth knowing when reasoning about it: the handler calls For a regression test: the existing tests cover |
|
Three corrections to my own comment above, after a second pass. The reorder I suggested swallows the let joined = handles.app.await;
if handles.sigterm_received.load(Ordering::SeqCst) {
if let Err(e) = &joined {
warn!(%e, "app task failed while SIGTERM cleanup was in progress");
}
handles.sigterm_done.notified().await;
return Err(eyre::eyre!("Received SIGTERM signal"));
}
let result = joined?;I overstated the consequence. I wrote that the savepoint "never runs". Runtime shutdown cancels async tasks at their yield points; it does not interrupt an already-running synchronous call, so if And I overstated the trigger. "That leaves a panic as the trigger here" is stronger than what I checked. An unwinding app-task panic is one case that bypasses the handover; I verified there is no Also, my regression-test sketch was too thin — a panicking task alone isn't sufficient. It needs the SIGTERM flag set first, cleanup held incomplete until the join is observed, and The |
Assessment of I wrote the analysis on #360 that this PR implements, so rather than re-reasoning about the diff I re-ran the same standalone model against it: tokio The core fix works
Two details that are easy to get wrong and that this diff gets right:
One correction to how the fix is likely to be read, restated from #360 so the change isn't credited with more than it does: the savepoint was never actually lost on this path. 1. The
|
| ordering | result |
|---|---|
| pre-patch | exits 0 (the #360 bug) |
| this patch | hangs indefinitely — still alive after 5 s, survived 3 further SIGTERMs, needed SIGKILL |
handover via the handler's JoinHandle instead |
exits 143 |
The "survived 3 further SIGTERMs" part is the sharp edge. Tokio's signal registration is process-global and stays installed after the handler task is gone, and install_sigterm_handler does sigterm.recv().await once — not in a loop — so there is no second-signal escape. In the hung state the process ignores SIGTERM entirely. Against the deployment docs in this repo (docs/running-an-arc-node.md, Restart=always + TimeoutStopSec=300) that's a 5-minute stall before systemd escalates to SIGKILL; on Kubernetes it's the grace period, then SIGKILL — the un-drained kill the PR is trying to avoid, just later.
To be fair about likelihood: this is not a probable crash. Store::savepoint swallows its errors (ensure_allocator_state_table().is_err() → warn!), and stop_and_wait is bounded at 10 s and its error is handled. So the window needs an unwinding panic from ractor or redb. The point is the shape, not the odds — the patch replaces a bounded wrong behaviour with an unbounded one, and the fix is free either way:
- Await the handler's
JoinHandle(store it inHandlein place ofsigterm_done, one field instead of two).JoinHandleresolves on panic as well as completion, so it cannot hang. Verified above: exits143. - Or wrap the existing wait:
let _ = timeout(Duration::from_secs(15), handles.sigterm_done.notified()).await;
Note that crates/node/src/main.rs:645-700 — the EL binary's SIGTERM handler — already has both guards this one lacks: timeout(Duration::from_secs(30), done_rx) and a nested task that force-exits 143 on a second SIGTERM.
3. @GG5533's ? finding is real — and the exit code is worse than 1 might suggest
Confirmed independently: let result = handles.app.await?; discharges the outer JoinError before the new flag check, so a JoinError skips the handover. Modelled with the app task panicking after the flag is set:
[306ms] app::run -> PANIC (JoinError to Node::run)
[306ms] main: Err -> exit 1
Worth adding to that thread: the observed exit is 1, not 143 — the string-match fallback doesn't catch a JoinError either, so this path loses the exit code and the handover. Their revised snippet (bind the join result, check the flag, log the JoinError, then ?) is the right shape; awaiting the handler's JoinHandle per §2 composes with it.
4. SIGTERM during the startup-failure park still exits 1
run's startup-failure branch parks in wait_for_termination() and then returns Err(startup_error), where startup_error = e.wrap_err("Node failed to start"). eyre's to_string() renders only the outermost message, so:
wrapped to_string : "Node failed to start"
contains "SIGTERM": false
chain : ["Node failed to start", "Received SIGTERM signal"]
That path exits 1. Non-zero is arguably right for a failed start, but by the PR's own rationale — "so the container orchestrator observes the correct termination reason" — a node that was parked waiting for SIGTERM and then received it is a SIGTERM termination. Either way it's a case the current mapping can't express, which is another argument for a typed signal over a string.
5. No regression test
The existing tests in node.rs cover stop_node_and_teardown and drain_before_exit in isolation; nothing covers the sequencing in run, which is the entire subject of #360, and this diff doesn't add one. As @GG5533 notes, a unit test needs the flag set, cleanup held incomplete until the join is observed, and process::exit stubbed or driven out-of-process. crates/test/framework/tests/errors.rs already spawns a process, so a subprocess-level assertion (send SIGTERM, assert exit 143 and that the drain log line appears) may be the lower-friction route than unit-testing run.
Scope
The diff correctly leaves the EL binary alone — as noted in §2 it already has the guards this handler is missing, so it isn't affected by #360.
Model caveats: this is a standalone binary reproducing the control flow, not arc-node itself. ractor's stop_and_wait is a timed stub that closes the consensus channel partway through (matching the real ordering, where stopping the Node actor closes the channel before the call returns), and redb is a no-op savepoint. It reproduces the pre-patch symptom and the post-patch fix, so I believe the orderings are faithful, but the exit-path ratio in §1 is scheduler-dependent and will differ on other hardware. Happy to share the model if useful. Nothing here is a maintainer decision — treat §2 as the one I'd not merge without, and the rest as calibration.
|
@osr21 — your correction on the savepoint is right and mine was too strong; thanks for the measurement. One refinement on where durability is implicated.
That panic then surfaces to
So: wrong on the ordinary path, and the panic row is the only one where durability is actually at stake — both savepoints, not one. Narrower than what I first wrote, and it needs the panic rather than following from the Your §2 is a better finding than mine and I'd prioritise it over the The two fixes also converge. Awaiting the handler's On §1 — agreed, and your 2/40 measurement settles something I could only state as a guess. I wrote that the mapping was "rarely exercised" with no basis for a frequency; you have one. Exiting inline in |
@GG5533 — I checked your panic-path claim rather than taking it, and then measured it. It holds, and it's a better finding than either of the two we'd been arguing about. One refinement matters for what the fix actually buys you, though. Verified first: the unwind assumptionBefore anything else — if this repo built with The rest of your source claims check out too: MeasuredStandalone model of
So the app-panic row is real and deterministic, not a narrow race: 10/10 exit 1 with the entire teardown lost. That is precisely the failure this PR exists to prevent, re-entering through a different door. The refinement: the fix recovers one savepoint, not bothYou wrote "both savepoints, not one". The measurement says otherwise, and I think this is the part worth carrying forward:
It can't. It's inside the task that's unwinding, so no change to how Whether that's worth doing is a separate call — the handler's savepoint may well be sufficient — but it shouldn't be folded into this PR's scope on the belief that the reorder covers it. The window is wider than it looksI mapped when the panic has to land to do damage, by varying the panic delay against a 300 ms node-stop:
The exposed window is the handler's whole runtime, not an instant — and it scales with the node stop. Re-running with a 2 s node-stop and a 1.5 s panic delay: 3/3 exit 1, no savepoint. Since Agreed on convergence, with one caveat on how §1 landsYour point that the One thing to watch when combining with §1: in the handler-panic case, the 143 comes from the Minor: the swap keeps the field count the same rather than going two-to-one — you still need On likelihood, to keep this honestI applied the same standard I used on the handler panic, and it cuts the same way: the production half of So this is a shape objection, not a live-bug report — same as §2. The argument for fixing it is that the fix is one line of ordering plus a field swap you're making anyway, and the failure mode is losing the drain that the PR was written to protect. Happy to re-run any row against a revised patch. |
This PR address #360
It fixes the race condition bug with minimal changes