-
Notifications
You must be signed in to change notification settings - Fork 238
feat(container-runner): drain children and engine concurrently on SIGTERM #5584
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stack/fix-envoy-client-ack-terminating-stop-commands-so-pegboard-envoy-stops-replaying-them-zrklppqy
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ use std::time::Duration; | |
|
|
||
| use anyhow::Result; | ||
| use clap::Parser; | ||
| use futures_util::future::join_all; | ||
| use rivetkit::serverless_http::{self, ListenerConfig}; | ||
| use rivetkit::{ActorConfig, EngineSpawnMode, Registry, ServeConfig}; | ||
| use tokio_util::sync::CancellationToken; | ||
|
|
@@ -103,30 +104,19 @@ static SIGNAL_SHUTDOWN: AtomicBool = AtomicBool::new(false); | |
| static PLATFORM_RECLAIM: AtomicBool = AtomicBool::new(false); | ||
|
|
||
| /// How long the platform gives this container between SIGTERM and SIGKILL. | ||
| /// Defaults to 10 seconds; keep this in sync with the platform's actual budget | ||
| /// via RIVET_SIGTERM_BUDGET_SECS. | ||
| /// The signal-path teardown splits the budget: ~60% for the engine drain | ||
| /// (whose per-actor stops SIGTERM children with a grace capped at ~40%), 1s | ||
| /// for the straggler sweep, and the rest as margin. | ||
| /// Defaults to 9s, one second under the common ~10s platform budget so the whole | ||
| /// teardown lands before SIGKILL; keep it in sync with the platform's actual | ||
| /// budget via RIVET_SIGTERM_BUDGET_SECS. On the signal path the engine drain and | ||
| /// the child kills run concurrently, each bounded by this full budget. | ||
| static SIGTERM_BUDGET: LazyLock<Duration> = LazyLock::new(|| { | ||
| let secs = std::env::var("RIVET_SIGTERM_BUDGET_SECS") | ||
| .ok() | ||
| .and_then(|value| value.parse::<u64>().ok()) | ||
| .unwrap_or(10) | ||
| .unwrap_or(9) | ||
| .max(3); | ||
| Duration::from_secs(secs) | ||
| }); | ||
|
|
||
| fn signal_drain_timeout() -> Duration { | ||
| SIGTERM_BUDGET.mul_f64(0.6) | ||
| } | ||
|
|
||
| fn signal_child_stop_grace() -> Duration { | ||
| SIGTERM_BUDGET.mul_f64(0.4) | ||
| } | ||
|
|
||
| const SIGNAL_SWEEP_GRACE: Duration = Duration::from_secs(1); | ||
|
|
||
| pub fn runner_config() -> Arc<RunnerConfig> { | ||
| RUNNER_CONFIG | ||
| .get() | ||
|
|
@@ -203,7 +193,7 @@ pub async fn release_child_port(port: u16) { | |
| pub fn effective_stop_grace() -> Duration { | ||
| let grace = runner_config().stop_grace; | ||
| if SIGNAL_SHUTDOWN.load(Ordering::Acquire) { | ||
| grace.min(signal_child_stop_grace()) | ||
| grace.min(*SIGTERM_BUDGET) | ||
| } else { | ||
| grace | ||
| } | ||
|
|
@@ -395,11 +385,13 @@ async fn async_main() -> Result<()> { | |
| // currently unreachable and kept only as a fallback for a future | ||
| // actor-driven exit. | ||
| // | ||
| // Signal (platform is reclaiming the instance): tell the engine FIRST so | ||
| // it can start re-placing actors immediately. Its per-actor stops run our | ||
| // on_destroy hooks, which SIGTERM children with the capped signal grace. | ||
| // The drain is bounded so an unreachable engine cannot eat the whole | ||
| // platform budget; the sweep then catches any child whose hooks never ran. | ||
| // Signal (platform is reclaiming the instance): kill our children AND | ||
| // notify the engine at the SAME time, each bounded by the full SIGTERM | ||
| // budget. The direct sweep is what guarantees children die within budget | ||
| // rather than waiting on an engine round-trip to run our on_destroy hooks; | ||
| // notifying the engine in parallel just lets it start re-placing actors | ||
| // immediately. Bounding the drain means an unreachable engine cannot eat | ||
| // the budget the children need. | ||
| // | ||
| // Fallback actor-driven exit (unreachable today): no platform deadline. | ||
| // Children are already reaped by the hooks (the sweep is a no-op backstop), | ||
|
|
@@ -417,13 +409,15 @@ async fn async_main() -> Result<()> { | |
| ) | ||
| .await; | ||
| } | ||
| if tokio::time::timeout(signal_drain_timeout(), runtime.shutdown()) | ||
| .await | ||
| .is_err() | ||
| { | ||
| tracing::warn!("engine drain exceeded the signal budget, sweeping children directly"); | ||
| } | ||
| stop_all_children(SIGNAL_SWEEP_GRACE).await; | ||
| let drain = async { | ||
| if tokio::time::timeout(*SIGTERM_BUDGET, runtime.shutdown()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| .await | ||
| .is_err() | ||
| { | ||
| tracing::warn!("engine drain exceeded the signal budget"); | ||
| } | ||
| }; | ||
| tokio::join!(drain, stop_all_children(*SIGTERM_BUDGET)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate concurrent stop of the same child (broken "backstop" invariant).
Because
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sweep bypasses
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [correctness] Duplicate concurrent stop on the same child (CONFIRMED)
Now that 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } else { | ||
| stop_all_children(stop_grace).await; | ||
| runtime.shutdown().await; | ||
|
|
@@ -442,7 +436,9 @@ async fn async_main() -> Result<()> { | |
|
|
||
| /// Stop every child still in the registry. Actor `on_destroy` normally reaps | ||
| /// its own child first; this is the belt-and-suspenders sweep for the signal | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [cleanup] Doc comment now misdescribes the actual ordering (CONFIRMED) This comment says on_destroy normally reaps its own child first and calls this a belt-and-suspenders sweep for stragglers - true when the sweep ran strictly after runtime.shutdown() finished/timed out. Now the function is invoked concurrently with the engine drain (which triggers on_destroy) via tokio::join!, so the sweep races the normal per-actor path on every signal shutdown rather than only catching leftovers. A future maintainer relying on this comments sequential framing (e.g. gating new logic in stop_child on the CHILDREN entry still being present) could introduce a real bug, since that gate would now silently almost never fire. |
||
| /// path so children are never orphaned. | ||
| /// path so children are never orphaned. Children are stopped concurrently so | ||
| /// each gets the full `grace` within the SIGTERM budget instead of queueing | ||
| /// behind the others. | ||
| async fn stop_all_children(grace: Duration) { | ||
| let mut children: Vec<Arc<ChildProcess>> = Vec::new(); | ||
| CHILDREN | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Resource-monitor attribution goes empty before children are actually dead (CONFIRMED) active_actor_ids() (main.rs:133-143) reports actor ids by scanning CHILDREN, and gates the resource monitors per-actor logging loop (monitor.rs:231-234). This retain_async sweep removes every entry from CHILDREN in one synchronous pass before join_all (below) has actually stopped any of the collected children. During a signal shutdown, active_actor_ids() reports zero active actors almost immediately even though the children are still alive and consuming resources for up to the full grace period while being SIGTERM/SIGKILLed - so resource samples during that window get dropped or misattributed to no actor. |
||
|
|
@@ -455,10 +451,11 @@ async fn stop_all_children(grace: Duration) { | |
| return; | ||
| } | ||
| println!("runner: shutdown, stopping {} child(ren)", children.len()); | ||
| for child in children { | ||
| join_all(children.into_iter().map(|child| async move { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Since a local SIGINT (developer Ctrl-C) also sets
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| child.stop(grace).await; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [cleanup] stop+release_child_port pair duplicated across three call sites (CONFIRMED) The exact pair child.stop(grace).await; release_child_port(child.child_port).await; (in that order) now appears independently in three places: here in stop_all_childrens join_all closure, in GameServer::stop_child (actor.rs:47-48), and in on_starts defensive registration-failure branch (actor.rs:187-188). A shared helper (e.g. a ChildProcess::stop_and_release() method) would keep this two-step invariant in one place instead of three independently-maintained copies that can silently drift (e.g. if the release-order or added logging/metrics is updated in one call site and forgotten in the others). |
||
| release_child_port(child.child_port).await; | ||
| } | ||
| })) | ||
| .await; | ||
| } | ||
|
|
||
| fn env_u16(key: &str) -> Option<u16> { | ||
|
|
||
There was a problem hiding this comment.
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. Nowdrainandstop_all_childreneach individually run up to the fullSIGTERM_BUDGET(default dropped 10s→9s) concurrently viatokio::join!, so the bounded phase alone can consume ~9 of the ~10s platform deadline.After that join,
async_mainstill doesserve_shutdown.cancel(); serve.await(main.rs:425-431), which waits onserverless_http::serve's graceful shutdown — unbounded by any timeout, and dependent on in-flight websocket/SSE connections (including the engine's own long-lived/startrequest) 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 boundingserve.awaitwith its own timeout, or keeping some of the previous margin unconsumed by the bounded phase.