Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions container-runner/Dockerfile.release
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
# -> ./dist/rivet-container-runner (x86_64 ELF)

FROM --platform=linux/amd64 rust:1-bookworm AS builder
# The build context excludes `.git` (see `.dockerignore`), so pass the commit SHA
# in to embed it in the binary's startup version log. Defaults to empty, which
# the build resolves to "unknown".
# docker build --build-arg OVERRIDE_GIT_SHA=$(git rev-parse HEAD) ...
ARG OVERRIDE_GIT_SHA=
ENV OVERRIDE_GIT_SHA=${OVERRIDE_GIT_SHA}
WORKDIR /build
# The crate depends on in-repo workspace crates (rivet-envoy-client), so the whole
# workspace is the build context. Build from the rivet repo root.
Expand Down
3 changes: 2 additions & 1 deletion container-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ This README covers development, the example projects, and the local test harness
spawning a child game-server process per actor and proxying Rivet's tunneled
HTTP/WebSocket traffic to it. Each child gets its own port; the pool's request
concurrency decides how many actors share a container (one, in the recommended
game-server setup), and the process exits when the last actor stops. Wrap any dedicated server (Unity, Godot, a plain Node process) in a
game-server setup). The instance stays warm after its last actor stops; the engine
reaps it by draining the `/start` connection after the request lifespan. Wrap any dedicated server (Unity, Godot, a plain Node process) in a
container with this binary as the entrypoint and Rivet Compute can cold-start and route
to it.

Expand Down
34 changes: 34 additions & 0 deletions container-runner/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! Captures the git commit SHA at build time and exposes it to the binary as the
//! `CONTAINER_RUNNER_GIT_SHA` compile-time env var.
//!
//! Resolution order, chosen so the build never fails when git is unavailable:
//! 1. `OVERRIDE_GIT_SHA` env var. The release image build context excludes
//! `.git` (see `.dockerignore`), so CI/Docker injects the SHA this way.
//! 2. A local `git rev-parse HEAD`, for colocated dev builds.
//! 3. `"unknown"`.

use std::process::Command;

fn main() {
println!("cargo:rerun-if-env-changed=OVERRIDE_GIT_SHA");

let git_sha = std::env::var("OVERRIDE_GIT_SHA")
.ok()
.filter(|sha| !sha.trim().is_empty())
.or_else(git_head_sha)
.unwrap_or_else(|| "unknown".to_string());

println!("cargo:rustc-env=CONTAINER_RUNNER_GIT_SHA={git_sha}");
}

fn git_head_sha() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?.trim().to_string();
if sha.is_empty() { None } else { Some(sha) }
}
100 changes: 86 additions & 14 deletions container-runner/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,29 @@
//! readiness (so the actor is never reported ready before the child listens),
//! `run` is a watchdog that reports unexpected child exits,
//! `on_fetch`/`on_websocket` proxy tunneled traffic to the child's port, and
//! `on_destroy` stops the child, exiting the process once no actors remain.
//! `on_destroy` stops the child while the instance stays warm for the next
//! placement.

use std::sync::Arc;
use std::sync::{Arc, LazyLock};

use anyhow::{Context, Result};
use async_trait::async_trait;
use rivetkit::{Actor, ActorKeySegment, Ctx, Request, Response, WebSocket, action};
use tokio::sync::Mutex as TokioMutex;

use crate::child::{ChildProcess, SpawnSpec, log_prefix};

Check warning on line 17 in container-runner/src/actor.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/actors/actors/container-runner/src/actor.rs
use crate::input::ActorInput;
use crate::{
children, effective_stop_grace, release_child_port, request_exit, reserve_child_port,
children, effective_stop_grace, release_child_port, reserve_child_port,
runner_config,
};

/// Live actor contexts on this instance, keyed by actor id. Lets the process
/// shutdown path report actors as crashed when the platform reclaims the
/// container out from under them.
static ACTOR_CTXS: LazyLock<scc::HashMap<String, Ctx<GameServer>>> =
LazyLock::new(scc::HashMap::new);

pub struct GameServer {
child: TokioMutex<Option<Arc<ChildProcess>>>,
}
Expand All @@ -34,17 +41,19 @@
// deliberate, then stop. `stop` is idempotent if the process shutdown
// sweep already stopped this child.
children().remove_async(actor_id).await;
ACTOR_CTXS.remove_async(actor_id).await;
let child = self.child.lock().await.take();
if let Some(child) = child {
child.stop(effective_stop_grace()).await;
release_child_port(child.child_port).await;
}

// Once the last actor is gone the instance drains rather than
// lingering for the next placement.
if children().is_empty() {
request_exit(actor_id, reason);
}
// 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");
}
}

Expand Down Expand Up @@ -76,6 +85,17 @@
let actor_id = ctx.actor_id().to_string();
let key = actor_key_string(&ctx);

// Surface the resource monitor's status here, tagged with the actor id, so
// it is visible in actor-scoped log views. The monitor's own enable/disable
// logs are process-level and have no actor id, so they are filtered out of
// those views.
tracing::info!(
actor_id = %actor_id,
resource_monitor_enabled = crate::monitor::enabled(),
resource_monitor_source = crate::monitor::sampling_source(),
"resource monitor status"
);

// An engine retry for an actor that is already running here must be an
// idempotent no-op: rejecting it would make the engine tear down a
// healthy actor.
Expand All @@ -85,6 +105,7 @@
"{} runner: actor already running, ignoring duplicate start",
log_prefix(&actor_id, existing.key.as_deref())
);
register_ctx(&actor_id, &ctx).await;
*self.child.lock().await = Some(existing);
return Ok(());
}
Expand Down Expand Up @@ -118,6 +139,23 @@
key: key.clone(),
};

// Version line, tagged with the actor id so it is visible in actor-scoped
// logs. `git_sha` is omitted entirely when unknown rather than logged as
// "unknown".
match crate::git_sha() {
Some(git_sha) => tracing::info!(
actor_id = %actor_id,
version = crate::VERSION,
git_sha = %git_sha,
"container-runner build"
),
None => tracing::info!(
actor_id = %actor_id,
version = crate::VERSION,
"container-runner build"
),
}

tracing::info!(
boot_id = crate::boot_id(),
actor_id = %actor_id,
Expand All @@ -129,12 +167,10 @@
Ok(child) => Arc::new(child),
Err(err) => {
release_child_port(child_port).await;
// A failed start on an otherwise idle instance poisons it;
// don't let it serve the next placement. With other actors
// running, the failure is this actor's alone.
if children().is_empty() {
request_exit(&actor_id, "child failed to start");
}
// A failed start is this actor's alone and does not take the
// instance down. The container stays warm and ready for the next
// placement, and stays alive long enough for the log agent to
// drain the failure logs before the platform reaps it.
return Err(err);
}
};
Expand All @@ -152,6 +188,10 @@
release_child_port(child_port).await;
anyhow::bail!("a child for actor {actor_id} is already registered");
}
// Register only now that startup has succeeded. Registering earlier would
// leak an entry for any generation whose start failed, since a failed
// start never runs on_destroy/on_sleep to remove it.
register_ctx(&actor_id, &ctx).await;
*self.child.lock().await = Some(child);
Ok(())
}
Expand Down Expand Up @@ -236,6 +276,38 @@
}
}

/// Register an actor context for crash-on-shutdown reporting. Overwrites any
/// stale entry left by a prior generation with the same id.
async fn register_ctx(actor_id: &str, ctx: &Ctx<GameServer>) {
ACTOR_CTXS.remove_async(actor_id).await;
let _ = ACTOR_CTXS
.insert_async(actor_id.to_string(), ctx.clone())
.await;
}

/// Report every live actor on this instance as crashed. Called when the
/// platform reclaims the container (an unexpected SIGTERM) so the reclaim
/// surfaces as a crash on the engine instead of a silent reallocation. Runs
/// while the envoy is still connected so the crash reaches the engine.
pub async fn crash_all_actors(message: &str) {
let mut ctxs = Vec::new();
ACTOR_CTXS
.retain_async(|_, ctx| {
ctxs.push(ctx.clone());
false
})
.await;
for ctx in ctxs {
if let Err(err) = ctx.stop_with_error(message) {
tracing::debug!(
actor_id = %ctx.actor_id(),
error = ?err,
"crash-on-shutdown stop_with_error failed"
);
}
}
}

fn actor_key_string(ctx: &Ctx<GameServer>) -> Option<String> {
let key = ctx.key();
if key.is_empty() {
Expand Down
2 changes: 1 addition & 1 deletion container-runner/src/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ impl ChildProcess {
"child port {child_port} is already in use before spawning `{program}`: a \
previous game server is still running in this container. container-runner \
hosts one actor per container — configure the serverless runner with \
max_concurrent_actors=1 and Cloud Run request concurrency=1."
max_concurrent_actors=1 and platform request concurrency=1."
);
}

Expand Down
Loading
Loading