Skip to content

Commit cc14c96

Browse files
committed
feat(container-runner): keep instance warm instead of self-exiting
1 parent 688589a commit cc14c96

4 files changed

Lines changed: 31 additions & 26 deletions

File tree

container-runner/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ This README covers development, the example projects, and the local test harness
88
spawning a child game-server process per actor and proxying Rivet's tunneled
99
HTTP/WebSocket traffic to it. Each child gets its own port; the pool's request
1010
concurrency decides how many actors share a container (one, in the recommended
11-
game-server setup), and the process exits when the last actor stops. Wrap any dedicated server (Unity, Godot, a plain Node process) in a
11+
game-server setup). The instance stays warm after its last actor stops; the engine
12+
reaps it by draining the `/start` connection after the request lifespan. Wrap any dedicated server (Unity, Godot, a plain Node process) in a
1213
container with this binary as the entrypoint and Rivet Compute can cold-start and route
1314
to it.
1415

container-runner/src/actor.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
//! readiness (so the actor is never reported ready before the child listens),
55
//! `run` is a watchdog that reports unexpected child exits,
66
//! `on_fetch`/`on_websocket` proxy tunneled traffic to the child's port, and
7-
//! `on_destroy` stops the child, exiting the process once no actors remain.
7+
//! `on_destroy` stops the child while the instance stays warm for the next
8+
//! placement.
89
910
use std::sync::Arc;
1011

@@ -16,7 +17,7 @@ use tokio::sync::Mutex as TokioMutex;
1617
use crate::child::{ChildProcess, SpawnSpec, log_prefix};
1718
use crate::input::ActorInput;
1819
use crate::{
19-
children, effective_stop_grace, release_child_port, request_exit, reserve_child_port,
20+
children, effective_stop_grace, release_child_port, reserve_child_port,
2021
runner_config,
2122
};
2223

@@ -40,11 +41,12 @@ impl GameServer {
4041
release_child_port(child.child_port).await;
4142
}
4243

43-
// Once the last actor is gone the instance drains rather than
44-
// lingering for the next placement.
45-
if children().is_empty() {
46-
request_exit(actor_id, reason);
47-
}
44+
// The instance stays alive and warm after its last actor stops, ready to
45+
// host the next placement. It is reaped by the platform's own shutdown
46+
// signal, not by self-exit. This keeps the serverless container long
47+
// lived enough for the log agent to drain its stderr, which a fast
48+
// self-exit could otherwise lose.
49+
tracing::info!(actor_id = %actor_id, reason, "actor stopped, keeping instance warm");
4850
}
4951
}
5052

@@ -129,12 +131,10 @@ impl Actor for GameServer {
129131
Ok(child) => Arc::new(child),
130132
Err(err) => {
131133
release_child_port(child_port).await;
132-
// A failed start on an otherwise idle instance poisons it;
133-
// don't let it serve the next placement. With other actors
134-
// running, the failure is this actor's alone.
135-
if children().is_empty() {
136-
request_exit(&actor_id, "child failed to start");
137-
}
134+
// A failed start is this actor's alone and does not take the
135+
// instance down. The container stays warm and ready for the next
136+
// placement, and stays alive long enough for the log agent to
137+
// drain the failure logs before the platform reaps it.
138138
return Err(err);
139139
}
140140
};

container-runner/src/main.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
//! The runner hosts as many concurrent actors as the engine places on it,
1313
//! each with its own child process on its own port; the pool's request
1414
//! concurrency decides how many that is (1 in the recommended game-server
15-
//! setup). When the last actor stops the process exits so the platform reaps
16-
//! the instance.
15+
//! setup). The instance stays warm after its last actor stops and never
16+
//! self-exits; the engine reaps it by draining the `/start` connection once
17+
//! the request lifespan elapses, or the platform sends a SIGTERM.
1718
1819
mod actor;
1920
mod child;
@@ -167,12 +168,12 @@ pub fn effective_stop_grace() -> Duration {
167168
}
168169
}
169170

170-
/// End the process. Called when the LAST actor on this instance is gone (or a
171-
/// failed start poisoned an otherwise idle instance): the instance drains
172-
/// instead of lingering for the next placement. The runner is PID 1 in the
171+
/// End the process. Only the platform shutdown signal drives this now: actors
172+
/// stopping or failing to start no longer exit the instance, so it stays warm
173+
/// and reusable and its logs have time to drain. The runner is PID 1 in the
173174
/// image, so exiting stops the container and the platform reaps the instance.
174175
pub fn request_exit(actor_id: &str, reason: &str) {
175-
tracing::info!(actor_id = %actor_id, reason, "actor finished, exiting container");
176+
tracing::info!(actor_id = %actor_id, reason, "shutting down container");
176177
EXIT.cancel();
177178
}
178179

@@ -346,18 +347,21 @@ async fn async_main() -> Result<()> {
346347
));
347348
tracing::info!(port, "container-runner serverless front door listening");
348349

349-
// Wait for an exit request, then tear down. Two orders depending on why:
350+
// Wait for an exit request, then tear down. Only the signal path is live
351+
// today: nothing calls `request_exit` except `spawn_signal_handler`, which
352+
// sets `SIGNAL_SHUTDOWN` before cancelling `EXIT`, so the `else` branch is
353+
// currently unreachable and kept only as a fallback for a future
354+
// actor-driven exit.
350355
//
351356
// Signal (platform is reclaiming the instance): tell the engine FIRST so
352357
// it can start re-placing actors immediately. Its per-actor stops run our
353358
// on_destroy hooks, which SIGTERM children with the capped signal grace.
354359
// The drain is bounded so an unreachable engine cannot eat the whole
355360
// platform budget; the sweep then catches any child whose hooks never ran.
356361
//
357-
// Actor-driven exit (last actor stopped or a failed start poisoned an
358-
// idle instance): no platform deadline. Children are already reaped by
359-
// the hooks (the sweep is a no-op backstop), and the runtime drains
360-
// unbounded so the /start SSE flushes its stopping frame cleanly.
362+
// Fallback actor-driven exit (unreachable today): no platform deadline.
363+
// Children are already reaped by the hooks (the sweep is a no-op backstop),
364+
// and the runtime drains unbounded so the /start SSE flushes cleanly.
361365
EXIT.cancelled().await;
362366
if SIGNAL_SHUTDOWN.load(Ordering::Acquire) {
363367
if tokio::time::timeout(signal_drain_timeout(), runtime.shutdown())

website/src/content/docs/deploy/container-runner.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Create actors against the pool's runner (`default`) and connect clients through
6565
2. The runner spawns your server as a child process with `PORT` set to the child port, waits for the port to open, and reports the actor as running.
6666
3. Gateway traffic for the actor arrives over Rivet's tunnel and is proxied to `127.0.0.1:<child port>`. WebSocket clients connect at the bare gateway URL with the `rivet` WebSocket subprotocol. Raw HTTP reaches the child under the `/request/*` prefix on the actor surface (the prefix is stripped before proxying); other paths are reserved for the runtime's own endpoints.
6767
4. Child stdout and stderr are re-emitted with an `[actorId=... key=...]` prefix so actor logs are attributed in the dashboard.
68-
5. When an actor stops, the runner sends its child `SIGTERM`, escalates to `SIGKILL` after a grace period, and exits the process once no actors remain.
68+
5. When an actor stops, the runner sends its child `SIGTERM` and escalates to `SIGKILL` after a grace period. The instance stays warm for the next placement rather than exiting; the engine reaps it by draining the `/start` connection after the request lifespan.
6969

7070
## Configuration
7171

0 commit comments

Comments
 (0)