From 332c1a86b35f330f19e356689fe12653b5b1553b Mon Sep 17 00:00:00 2001 From: ABCxFF <79597906+abcxff@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:37:51 -0400 Subject: [PATCH] feat(container-runner): exit process when the last child stops --- container-runner/src/actor.rs | 22 +++++++++++++++------- container-runner/src/main.rs | 21 +++++++++------------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/container-runner/src/actor.rs b/container-runner/src/actor.rs index 800618ca49..7a8d3cea2d 100644 --- a/container-runner/src/actor.rs +++ b/container-runner/src/actor.rs @@ -18,7 +18,7 @@ use crate::child::{ChildProcess, SpawnSpec, log_prefix}; use crate::input::ActorInput; use crate::{ children, drain_grace, effective_stop_grace, exit_token, release_child_port, - reserve_child_port, runner_config, + request_exit, reserve_child_port, runner_config, }; /// Live actor contexts on this instance, keyed by actor id. Lets the process @@ -48,12 +48,20 @@ impl GameServer { release_child_port(child.child_port).await; } - // The instance stays alive and warm after its last actor stops, ready to - // host the next placement. It is reaped by the platform's own shutdown - // signal, not by self-exit. This keeps the serverless container long - // lived enough for the log agent to drain its stderr, which a fast - // self-exit could otherwise lose. - tracing::info!(actor_id = %actor_id, reason, "actor stopped, keeping instance warm"); + // Exit the whole process once the last child on this instance stops. The + // runner is PID 1, so `request_exit` cancels `EXIT`, which wakes `main` + // to run the graceful envoy close and then return, stopping the container + // so the platform reaps it. Guarded on an empty registry so a multi-actor + // instance does not tear down siblings still hosting a child. + if children().is_empty() { + request_exit(actor_id, reason); + } else { + tracing::info!( + actor_id = %actor_id, + reason, + "actor stopped, other actors still running on this instance" + ); + } } /// Engine pause path (sleep, lost, going-away). Give the child up to diff --git a/container-runner/src/main.rs b/container-runner/src/main.rs index 04f5f492ba..5c2bf9bd5f 100644 --- a/container-runner/src/main.rs +++ b/container-runner/src/main.rs @@ -217,10 +217,10 @@ pub fn effective_stop_grace() -> Duration { *SIGTERM_BUDGET } -/// End the process. Only the platform shutdown signal drives this now: actors -/// stopping or failing to start no longer exit the instance, so it stays warm -/// and reusable and its logs have time to drain. The runner is PID 1 in the -/// image, so exiting stops the container and the platform reaps the instance. +/// End the process. Driven by a platform shutdown signal or by the last child on +/// this instance stopping (see `stop_child`). The runner is PID 1 in the image, +/// so cancelling `EXIT` wakes `main` to run the graceful envoy close and return, +/// which stops the container and lets the platform reap the instance. pub fn request_exit(actor_id: &str, reason: &str) { tracing::info!(actor_id = %actor_id, reason, "shutting down container"); EXIT.cancel(); @@ -394,11 +394,7 @@ async fn async_main() -> Result<()> { )); tracing::info!(port, "container-runner serverless front door listening"); - // Wait for an exit request, then tear down. Only the signal path is live - // today: nothing calls `request_exit` except `spawn_signal_handler`, which - // sets `SIGNAL_SHUTDOWN` before cancelling `EXIT`, so the `else` branch is - // currently unreachable and kept only as a fallback for a future - // actor-driven exit. + // Wait for an exit request, then tear down. Two shapes depending on why: // // Signal (platform is reclaiming the instance): kill our children AND // notify the engine at the SAME time, each bounded by the full SIGTERM @@ -408,9 +404,10 @@ async fn async_main() -> Result<()> { // 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), - // and the runtime drains unbounded so the /start SSE flushes cleanly. + // Actor-driven exit (the last child stopped, so `stop_child` cancelled + // `EXIT`): no platform deadline. The child is already reaped by the hook + // that triggered the exit (the sweep is a no-op backstop), and the runtime + // drains unbounded so the /start SSE flushes cleanly. EXIT.cancelled().await; if SIGNAL_SHUTDOWN.load(Ordering::Acquire) { // A platform SIGTERM reclaims this instance. Report every actor as crashed