Skip to content

feat(container-runner): drain children and engine concurrently on SIGTERM - #5584

Open
abcxff wants to merge 1 commit into
stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqyfrom
stack/feat-container-runner-drain-children-and-engine-concurrently-on-sigterm-tpptkxqq
Open

feat(container-runner): drain children and engine concurrently on SIGTERM#5584
abcxff wants to merge 1 commit into
stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqyfrom
stack/feat-container-runner-drain-children-and-engine-concurrently-on-sigterm-tpptkxqq

Conversation

@abcxff

@abcxff abcxff commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@railway-app

railway-app Bot commented Aug 21, 2026

Copy link
Copy Markdown

🚅 Deployed to the actors-pr-5584 environment in rivet-frontend

Service Status Web Updated (UTC)
website ❌ Build Failed (View Logs) Web Aug 28, 2026 at 7:21 pm
frontend-cloud 😴 Sleeping (View Logs) Web Aug 28, 2026 at 6:57 pm
kitchen-sink 😴 Sleeping (View Logs) Web Aug 22, 2026 at 2:35 pm
frontend-inspector 😴 Sleeping (View Logs) Web Aug 22, 2026 at 1:14 pm
ladle ✅ Success (View Logs) Web Aug 21, 2026 at 9:41 pm
mcp-hub ✅ Success (View Logs) Web Aug 21, 2026 at 9:40 pm

@abcxff
abcxff force-pushed the stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqy branch from cbecbab to 203b9d3 Compare August 24, 2026 14:44
@abcxff
abcxff force-pushed the stack/feat-container-runner-drain-children-and-engine-concurrently-on-sigterm-tpptkxqq branch from 729e8f8 to 8b1ec5f Compare August 24, 2026 14:46
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review

Small, focused change: container-runner's SIGTERM teardown moves from a sequential 60%/40%/1s split-budget shutdown to a fully-concurrent, full-budget shutdown (engine drain and direct child kill race each other, each bounded by the whole SIGTERM_BUDGET; children are also stopped concurrently via join_all instead of sequentially). The rationale in the updated comments is clear and the mechanics check out, no hard correctness bug found. Two lower-confidence points worth a look:

Potential issues

  1. Redundant concurrent stop on the same child (main.rs:412, stop_all_children): The direct child-kill sweep now runs unconditionally from the start of shutdown, concurrently with the engine drain. Previously the sweep only fired after the drain timed out, so it mostly caught stragglers whose on_destroy never ran. Now, on essentially every normal signal shutdown, stop_all_children races GameServer::stop_child (invoked via on_destroy) for the same Arc<ChildProcess> almost every time. ChildProcess::stop() handles this safely (has_exited()/SIGTERM/SIGKILL/wait_exit() are all safe to call twice), but it does mean duplicate SIGTERM/SIGKILL signals and duplicate log lines under the common case rather than just the straggler case. The comment in actor.rs:43 ("stop is idempotent...") acknowledges this, so it's likely intentional, but worth double-checking this is the desired steady-state behavior and not just a tolerated side effect.

  2. Less slack for the unbounded post-SIGKILL reap (main.rs:193, effective_stop_grace): effective_stop_grace() now caps an individual child's grace at the full SIGTERM_BUDGET (was ~40% before), while the default budget itself shrank from 10s to 9s. ChildProcess::stop() sends SIGKILL after grace elapses, then awaits wait_exit() with no further timeout; if a child is wedged in an uninterruptible D-state wait, that final reap can take arbitrarily long. With grace now able to consume nearly the entire 9s budget, there's much less margin left for that unbounded reap plus the subsequent serve_shutdown.cancel()/serve.await teardown before the platform's own SIGKILL of the whole container (only 1s past the runner's budget) lands mid-teardown. This edge case existed before too, just with more buffer; probably fine given the PR's explicit goal of maximizing budget usage, but flagging since the safety margin is now much thinner.

Other notes

  • Style: comments are well-written, explain the why, and match the repo's no-em-dash / complete-sentence convention.
  • Test coverage: no tests accompany this change, and there do not appear to be existing unit tests around the shutdown path in container-runner/src. Signal-driven concurrent shutdown is inherently hard to unit test, so this may be acceptable, but consider whether the e2e harness in container-runner/examples/e2e-test could exercise the new concurrent-drain path, or whether it is worth a note on why it is untested.
  • Security: no concerns; this is process-local signal handling over already-trusted child processes, no new external input handled.
  • Performance: net positive; using the full budget concurrently instead of splitting it should reduce the chance of premature SIGKILL under load, and join_all over children stopping is a reasonable improvement over the previous sequential loop.

Overall this looks like a solid, well-reasoned tightening of the shutdown budget; the two points above are worth a quick sanity check but are not blockers.

@abcxff
abcxff force-pushed the stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqy branch from 203b9d3 to 3411262 Compare August 25, 2026 19:30
@abcxff
abcxff force-pushed the stack/feat-container-runner-drain-children-and-engine-concurrently-on-sigterm-tpptkxqq branch from 8b1ec5f to b062fac Compare August 25, 2026 19:36
@abcxff
abcxff force-pushed the stack/feat-container-runner-drain-children-and-engine-concurrently-on-sigterm-tpptkxqq branch from b062fac to e0fd0b9 Compare August 28, 2026 19:21
@abcxff
abcxff force-pushed the stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqy branch from 3411262 to 346d4c7 Compare August 28, 2026 19:21
tracing::warn!("engine drain exceeded the signal budget");
}
};
tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate concurrent stop of the same child (broken "backstop" invariant).

stop_all_children(*SIGTERM_BUDGET) now runs concurrently with the engine drain via tokio::join!, instead of only after the drain completes/times out. stop_all_children's retain_async grabs its own Arc<ChildProcess> from CHILDREN and calls child.stop(...), while each actor's own on_destroyGameServer::stop_child (actor.rs:39-49) independently grabs its own Arc (from self.child) and also calls .stop(...) on the very same underlying ChildProcess. Neither side checks whether the other already claimed the child.

Because ChildProcess::stop() only early-returns via has_exited() (child.rs:209), two concurrent calls both pass that check, both send SIGTERM, and (if the process does not exit within grace) both send SIGKILL and print duplicate "sending SIGTERM"/"sending SIGKILL"/"child stopped/killed" log lines for a single pid on effectively every signal shutdown — not just the rare straggler case the doc comment still describes ("belt-and-suspenders sweep... so children are never orphaned"). This is now guaranteed duplicate work rather than a true backstop.

tracing::warn!("engine drain exceeded the signal budget");
}
};
tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sweep bypasses effective_stop_grace(), so it ignores a shorter configured --stop-grace-secs in exactly the fallback case it exists for.

effective_stop_grace() (line 193-200) is documented as "the configured --stop-grace-secs normally, capped to the platform budget" and is what on_destroy uses via stop_child. But stop_all_children on the signal path is called directly with the raw *SIGTERM_BUDGET (line 420), not effective_stop_grace().

In the common case this is masked because both stop paths race on the same pid and whichever sends SIGKILL first wins (see the sibling comment on this line). But in the exact scenario this sweep exists for — the engine drain hangs/never invokes on_destroy for an actor — the sweep is the only path stopping that child, and it will wait up to the full SIGTERM_BUDGET (9s default) before SIGKILL, silently overriding an operator's explicit shorter --stop-grace-secs. Consider threading effective_stop_grace() (or stop_grace.min(*SIGTERM_BUDGET)) into the sweep's grace instead of the raw budget.

.unwrap_or(9)
.max(3);
Duration::from_secs(secs)
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new full-budget-concurrent model leaves almost no margin for the still-unbounded shutdown tail.

Previously the signal path was bounded to ~61% of SIGTERM_BUDGET (60% drain + 1s sweep) inside a 10s budget, leaving roughly 3s of slack. Now drain and stop_all_children each individually run up to the full SIGTERM_BUDGET (default dropped 10s→9s) concurrently via tokio::join!, so the bounded phase alone can consume ~9 of the ~10s platform deadline.

After that join, async_main still does serve_shutdown.cancel(); serve.await (main.rs:425-431), which waits on serverless_http::serve's graceful shutdown — unbounded by any timeout, and dependent on in-flight websocket/SSE connections (including the engine's own long-lived /start request) draining. If any such connection is still open when the join finishes, this tail step has roughly 1s of margin instead of the previous ~3s before the platform's real SIGKILL lands, making a hard kill mid-exit more likely under load. Consider bounding serve.await with its own timeout, or keeping some of the previous margin unconsumed by the bounded phase.

}
println!("runner: shutdown, stopping {} child(ren)", children.len());
for child in children {
join_all(children.into_iter().map(|child| async move {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sweep can now swallow a real child crash as a "deliberate stop" during a plain SIGINT.

stop_all_children's retain_async (main.rs:444-449) removes every actor's entry from the global CHILDREN map essentially as soon as this function starts running inside tokio::join! (line 420) — well before the child has actually exited, and well before on_destroy's own paced per-actor teardown would reach it. GameServer::run()'s watchdog (actor.rs:204-229) decides "was this exit deliberate" by racing children().remove_async(&actor_id) (actor.rs:212) against the child's actual exit: if the entry is already gone by the time the child exits, run() treats it as a deliberate stop and takes no action, even if the child actually crashed independently.

Since a local SIGINT (developer Ctrl-C) also sets SIGNAL_SHUTDOWN and takes this same tokio::join! branch (line 400) without calling crash_all_actors (that's gated on PLATFORM_RECLAIM, which SIGINT never sets, line 406), a child that happens to crash independently around the same time as a Ctrl-C shutdown will have its CHILDREN entry vacuumed by the sweep before run() observes the exit, so the crash is silently reported as a clean/deliberate stop instead of an error. In the old sequential code the sweep only ran after the drain completed or timed out, so it could not race ahead of a genuine crash like this.

tracing::warn!("engine drain exceeded the signal budget");
}
};
tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Duplicate concurrent stop on the same child (CONFIRMED)

stop_all_children (this tokio::join!) and GameServer::stop_child (called from on_destroy/on_sleep in actor.rs:39-57) each hold an independent Arc<ChildProcess> for the same child: one via the global CHILDREN map, one via GameServer.childs TokioMutex. Previously the drain ran to completion (or timed out) before the sweep, so in the common case on_destroy already stopped and removed the child and the sweep was a true no-op backstop.

Now that drain and stop_all_children(*SIGTERM_BUDGET) run concurrently, both code paths can call child.stop() on the same ChildProcess at the same time on every ordinary signal shutdown, not just as a rare straggler case. ChildProcess::stop() (child.rs:206-230) has no synchronization between them, so this produces duplicate SIGTERM/SIGKILL sends and duplicate release_child_port calls, plus a narrow TOCTOU window between the two callers unsynchronized has_exited() checks (a stray signal could hit a recycled pid).

Failure scenario: SIGTERM arrives with actor A running. stop_all_childrens retain_async grabs As child and starts stop(). Concurrently, the engine drain reaches As on_destroy -> stop_child, which independently takes self.child and also calls stop(). Both send SIGTERM/SIGKILL to the same pid and log independently: duplicated work on every shutdown, contradicting the belt-and-suspenders framing in the surviving doc comment on stop_all_children.

tracing::warn!("engine drain exceeded the signal budget");
}
};
tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Sweep defeats the watchdogs deliberate-vs-unexpected-exit detection (CONFIRMED)

stop_all_childrens retain_async sweep (lines 442-449) removes every actors entry from the global CHILDREN map essentially immediately when this join starts polling, well before each actors own on_destroy (paced by the engine drain) would normally reach it and remove it itself. GameServer::run()s watchdog (actor.rs:199-230) decides deliberate vs unexpected exit by racing children().remove_async(actor_id) against the childs actual exit: whichever caller removes the entry first wins.

Since the sweep now wins that race for essentially every actor almost immediately (not just stragglers), if a child crashes independently around the time of a SIGINT (developer Ctrl-C - note this path does NOT call crash_all_actors, since that is gated on PLATFORM_RECLAIM which is only set for an actual platform SIGTERM), run() will find the CHILDREN entry already gone and silently treat the crash as a deliberate stop instead of reporting an errored/crashed actor.

Failure scenario: a developer hits Ctrl-C while a game-server child is independently crash-looping. The sweep evacuates CHILDREN within microseconds of the signal handler firing. The childs subsequent unexpected exit is picked up by run(), but remove_async returns None (already removed by the sweep), so no anyhow::bail! and no ctx.destroy() - the crash is silently absorbed.

}
stop_all_children(SIGNAL_SWEEP_GRACE).await;
let drain = async {
if tokio::time::timeout(*SIGTERM_BUDGET, runtime.shutdown())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Shutdown safety margin shrunk while unbounded steps sit outside the timeout (CONFIRMED)

The tokio::join! bounds drain and stop_all_children each to *SIGTERM_BUDGET (9s by default, down from a 10s budget that was explicitly split 60/40/1s with a documented margin). But crash_all_actors (lines 406-411, before the join) and serve_shutdown.cancel(); serve.await (lines 425-431, after the join) are not bounded by any timeout tied to SIGTERM_BUDGET. serve.await ultimately awaits axum::serve(...).with_graceful_shutdown(...), which waits unboundedly for in-flight connections (proxied websockets, the engines long-lived /start SSE request) to close.

The new doc comment on SIGTERM_BUDGET only says "9s, one second under the common ~10s budget" without re-deriving how two now-fully-concurrent, full-budget waits plus this unbounded post-join tail interact with that 1s margin.

Failure scenario: a child takes close to the full 9s to die (SIGTERM ignored, grace elapses, SIGKILL sent, exit reaped near t=9s). The join returns near 9.x s. serve.await then still needs to drain an in-flight websocket/SSE connection, which has no timeout - pushing total wall-clock past the platforms real ~10s SIGKILL deadline and getting the process killed mid-shutdown rather than exiting cleanly.

}
println!("runner: shutdown, stopping {} child(ren)", children.len());
for child in children {
join_all(children.into_iter().map(|child| async move {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] PID-recycling TOCTOU from the concurrent double-stop (PLAUSIBLE)

ChildProcess::stop() (child.rs:206-230) checks has_exited() and returns early, otherwise sends SIGTERM, waits, and possibly sends SIGKILL, with no locking against a second concurrent caller. Given the double-stop race above (stop_all_children and on_destroys stop_child can both call stop() on the same ChildProcess concurrently), theres a narrow window where both callers pass the has_exited() check before either sends a signal. If the process exits and its pid is recycled by the OS between one callers stale check and the others signal::kill(), the stray second signal could be delivered to an unrelated process holding the recycled pid.

This requires fast pid recycling within the shutdown window to actually misfire, so its realistic but not certain from the code alone - a real TOCTOU class of bug for pid-based signaling that the previous sequential (drain-then-sweep) design avoided by construction.

tracing::warn!("engine drain exceeded the signal budget");
}
};
tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Grace-value mismatch between the two concurrent stop paths (CONFIRMED)

effective_stop_grace() returns grace.min(*SIGTERM_BUDGET) and is used by GameServer::stop_child (actor.rs:47, the on_destroy/on_sleep path). But this join calls stop_all_children(*SIGTERM_BUDGET) directly, not through effective_stop_grace(). If --stop-grace-secs/RIVET_STOP_GRACE_SECS is configured below SIGTERM_BUDGET (e.g. 3s vs the 9s default budget), the per-actor on_destroy path uses a 3s grace while the concurrently-racing sweep uses the full 9s grace for potentially the same child - an unintended divergence from the operators configured stop_grace intent.

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