diff --git a/Cargo.lock b/Cargo.lock index f9fbd72dc7..f3a32a6316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5273,24 +5273,20 @@ name = "rivet-container-runner" version = "2.3.3-rc.2" dependencies = [ "anyhow", - "axum 0.8.4", - "bytes", + "async-trait", "clap", - "futures", "futures-util", - "http 1.3.1", "nix 0.30.1", "reqwest 0.12.22", - "rivet-envoy-client", + "rivetkit", + "scc", "serde", "serde_json", "tokio", - "tokio-stream", "tokio-tungstenite", "tokio-util", "tracing", "tracing-subscriber", - "url", ] [[package]] diff --git a/container-runner/Cargo.toml b/container-runner/Cargo.toml index 41564cf88a..e01f51bc66 100644 --- a/container-runner/Cargo.toml +++ b/container-runner/Cargo.toml @@ -14,21 +14,17 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true -axum.workspace = true -bytes.workspace = true +async-trait.workspace = true clap = { workspace = true, features = ["env"] } -futures.workspace = true futures-util.workspace = true -http.workspace = true nix = { workspace = true, features = ["process"] } reqwest = { workspace = true, features = ["stream"] } -rivet-envoy-client = { workspace = true, features = ["native-transport"] } +rivetkit.workspace = true +scc.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true -tokio-stream.workspace = true tokio-tungstenite.workspace = true tokio-util = { workspace = true, features = ["rt"] } tracing.workspace = true tracing-subscriber.workspace = true -url.workspace = true diff --git a/container-runner/README.md b/container-runner/README.md index 4ce8555e43..e020b64060 100644 --- a/container-runner/README.md +++ b/container-runner/README.md @@ -1,9 +1,14 @@ # Container Runner -`rivet-container-runner` is a Rivet serverless runner that hosts one actor by spawning a -child game-server process and proxying Rivet's tunneled HTTP/WebSocket traffic to it. -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. +`rivet-container-runner` is a RivetKit (Rust) serverless app that hosts one actor by +spawning a child game-server process and proxying Rivet's tunneled HTTP/WebSocket +traffic to it. 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. + +The actor `input` payload (CBOR-encoded, RivetKit convention) can override the child +command, args, env, and port per actor. WebSocket clients connect at the bare gateway +path; raw HTTP reaches the child under the `/request/*` prefix on the actor surface. `examples/` contains a Unity + FishNet demo project, a lightweight Node test server, and an end-to-end test harness. All commands below run from the rivet repo root. diff --git a/container-runner/examples/e2e-test/create-actor.mjs b/container-runner/examples/e2e-test/create-actor.mjs index e4a98776bf..4578d041e2 100644 --- a/container-runner/examples/e2e-test/create-actor.mjs +++ b/container-runner/examples/e2e-test/create-actor.mjs @@ -4,6 +4,7 @@ // POST /actors?namespace=default (no auth) // The opaque `input` (base64) carries the child launch params decoded by container-runner. import { writeFileSync } from "node:fs"; +import { encode as cborEncode } from "cbor-x"; import { ENGINE, ENGINE_PUBLIC, @@ -18,12 +19,13 @@ import { const key = process.env.ACTOR_KEY || "match-1"; const outputJson = process.argv.includes("--json") || process.env.CREATE_ACTOR_OUTPUT === "json"; -// container-runner decodes this JSON from ActorConfig.input. Override the whole object -// via ACTOR_INPUT (JSON) to exercise command/args/env/port; defaults to { port: 7770 }. +// container-runner decodes this from the actor input as CBOR (the RivetKit wire +// encoding). Override the whole object via ACTOR_INPUT (JSON) to exercise +// command/args/env/port; defaults to { port: 7770 }. const inputJson = process.env.ACTOR_INPUT ? JSON.parse(process.env.ACTOR_INPUT) : { port: 7770 }; -const input = Buffer.from(JSON.stringify(inputJson)).toString("base64"); +const input = Buffer.from(cborEncode(inputJson)).toString("base64"); const body = { name: ACTOR_NAME, @@ -59,7 +61,9 @@ if (!actorId) { process.exit(1); } const gatewayPath = actorGatewayPath(actorId); -const gatewayHttpUrl = actorGatewayHttpUrl(actorId); +// Raw HTTP reaches the child under the /request/* prefix on the actor surface; +// WebSockets connect at the bare path. +const gatewayHttpUrl = actorGatewayHttpUrl(actorId, "/request/"); const gatewayWsUrl = actorGatewayWsUrl(actorId); writeFileSync(new URL("./.last-actor-id", import.meta.url), actorId); diff --git a/container-runner/examples/e2e-test/create-cloud-actor.mjs b/container-runner/examples/e2e-test/create-cloud-actor.mjs index db2236a5d0..e32645ba14 100644 --- a/container-runner/examples/e2e-test/create-cloud-actor.mjs +++ b/container-runner/examples/e2e-test/create-cloud-actor.mjs @@ -8,6 +8,7 @@ // Game clients cannot send an Authorization header (Bayou/browser WebSockets), so the // token is moved into the gateway path as `@` instead. import { writeFileSync } from "node:fs"; +import { encode as cborEncode } from "cbor-x"; const raw = process.env.RIVET_URL; if (!raw) { @@ -38,8 +39,8 @@ const actorName = process.env.RIVET_ACTOR_NAME || "game"; // The managed pool the Rivet CLI creates is named `default`, not the actor name. const runnerName = process.env.RIVET_RUNNER_NAME || "default"; -// container-runner decodes this from ActorConfig.input to launch its child. -const input = Buffer.from(JSON.stringify({ port: 7770 })).toString("base64"); +// container-runner decodes this from the actor input as CBOR to launch its child. +const input = Buffer.from(cborEncode({ port: 7770 })).toString("base64"); const res = await fetch(`${origin}/actors?namespace=${encodeURIComponent(namespace)}`, { method: "POST", diff --git a/container-runner/examples/e2e-test/host/run-host-tests.sh b/container-runner/examples/e2e-test/host/run-host-tests.sh index ca55c7cc51..a81402efcf 100755 --- a/container-runner/examples/e2e-test/host/run-host-tests.sh +++ b/container-runner/examples/e2e-test/host/run-host-tests.sh @@ -71,12 +71,14 @@ L="$LOGS/s1.log"; start_runner "$L" [ -z "${POOL_CONFIGURED:-}" ] && { node "$E2E/configure-serverless.mjs" >/dev/null; POOL_CONFIGURED=1; } create_actor "fetch-$$" '{"port":7770}'; wait_ready "$L" # The actor becomes guard-routable shortly after it reports ready; retry briefly. +# rivetkit's actor surface delivers raw HTTP to the child only under the +# /request/* prefix (stripped before proxying); bare paths are framework routes. for _ in $(seq 1 15); do - [ "$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/health")" = "healthy" ] && break; sleep 1 + [ "$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/request/health")" = "healthy" ] && break; sleep 1 done -H=$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/health") -R=$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/") -P=$(curl -s -X POST --data 'hello-body' "$ENGINE_URL/gateway/$ACTOR_ID/reflect") +H=$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/request/health") +R=$(curl -s "$ENGINE_URL/gateway/$ACTOR_ID/request/") +P=$(curl -s -X POST --data 'hello-body' "$ENGINE_URL/gateway/$ACTOR_ID/request/reflect") [ "$H" = "healthy" ] && ok "GET /health -> healthy" || bad "GET /health -> '$H'" [ "$R" = "ok" ] && ok "GET / -> ok" || bad "GET / -> '$R'" echo "$P" | grep -q '"method":"POST"' && ok "POST method proxied" || bad "POST method ('$P')" diff --git a/container-runner/examples/e2e-test/load-test.mjs b/container-runner/examples/e2e-test/load-test.mjs index f802aa8a98..80daea737b 100644 --- a/container-runner/examples/e2e-test/load-test.mjs +++ b/container-runner/examples/e2e-test/load-test.mjs @@ -22,6 +22,7 @@ // RIVET_TOKEN Bearer for the engine API (cloud) (default: none / local) // ENGINE_URL, ENGINE_PUBLIC_URL, RIVET_NAMESPACE, RIVET_ACTOR_NAME, RIVET_RUNNER_NAME — see common.mjs import WebSocket from "ws"; +import { encode as cborEncode } from "cbor-x"; import { ENGINE, ENGINE_PUBLIC, NAMESPACE, ACTOR_NAME, RUNNER_NAME } from "./common.mjs"; // ---- helpers ---- @@ -80,7 +81,7 @@ async function createActor(i) { const body = { name: ACTOR_NAME, key: `load-${RUN_ID}-${i}`, - input: Buffer.from(JSON.stringify(input)).toString("base64"), + input: Buffer.from(cborEncode(input)).toString("base64"), runner_name_selector: POOL_PREFIX ? `${POOL_PREFIX}-${i}` : RUNNER_NAME, crash_policy: "destroy", ...(DATACENTER ? { datacenter: DATACENTER } : {}), diff --git a/container-runner/examples/e2e-test/package-lock.json b/container-runner/examples/e2e-test/package-lock.json index 7897038841..fd4e356ef9 100644 --- a/container-runner/examples/e2e-test/package-lock.json +++ b/container-runner/examples/e2e-test/package-lock.json @@ -8,9 +8,144 @@ "name": "e2e-test", "version": "0.1.0", "dependencies": { + "cbor-x": "^1.6.0", "ws": "^8.18.0" } }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz", + "integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/container-runner/examples/e2e-test/package.json b/container-runner/examples/e2e-test/package.json index aae8f61cdf..1aca65a640 100644 --- a/container-runner/examples/e2e-test/package.json +++ b/container-runner/examples/e2e-test/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Scripts to drive the local Rivet <-> container-runner end-to-end test.", "dependencies": { + "cbor-x": "^1.6.0", "ws": "^8.18.0" } } diff --git a/container-runner/src/actor.rs b/container-runner/src/actor.rs new file mode 100644 index 0000000000..eee6681f13 --- /dev/null +++ b/container-runner/src/actor.rs @@ -0,0 +1,203 @@ +//! The `GameServer` actor: wraps one child game-server process per actor. +//! +//! Lifecycle: `on_start` spawns the child and waits for 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 local port, and `on_destroy` stops +//! the child then asks the process to exit (one actor per container). + +use std::sync::Arc; + +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}; +use crate::input::ActorInput; +use crate::{children, request_exit, runner_config}; + +pub struct GameServer { + child: TokioMutex>>, +} + +#[async_trait] +impl Actor for GameServer { + // The launch spec is the persisted state: a woken actor restores the same + // spec without the engine re-sending input. + type State = ActorInput; + type Input = ActorInput; + type Actions = (); + type Events = (); + type Queue = (); + type ConnParams = (); + type ConnState = (); + type Action = action::Raw; + + async fn create_state(_ctx: &Ctx, input: Self::Input) -> Result { + Ok(input) + } + + async fn create(_ctx: &Ctx) -> Result { + Ok(Self { + child: TokioMutex::new(None), + }) + } + + async fn on_start(self: Arc, ctx: Ctx) -> Result<()> { + let cfg = runner_config(); + let actor_id = ctx.actor_id().to_string(); + let key = actor_key_string(&ctx); + + let spec = { + let input = ctx.state(); + let child_port = input.port.unwrap_or(cfg.default_child_port); + + // input.command overrides the CLI template; input.args are appended. + let mut parts = input + .command + .clone() + .unwrap_or_else(|| cfg.command_template.clone()); + if parts.is_empty() { + anyhow::bail!( + "no child command: CLI template is empty and input.command was not provided" + ); + } + parts.extend(input.args.clone()); + let program = parts.remove(0); + + SpawnSpec { + program, + args: parts, + env: input.env.clone(), + child_port, + actor_id: actor_id.clone(), + key: key.clone(), + } + }; + + tracing::info!( + boot_id = crate::boot_id(), + actorId = %actor_id, + "actor starting on this container instance" + ); + + let child = match ChildProcess::spawn(spec, cfg.readiness_timeout).await { + Ok(child) => Arc::new(child), + Err(err) => { + // A failed start leaves this instance poisoned; don't let it + // serve another actor (parity with the old request_exit on a + // failed start). + request_exit(&actor_id, "child failed to start"); + return Err(err); + } + }; + + // The global registry lets the process shutdown path stop children + // even when actor hooks never run, and arbitrates the deliberate-stop + // vs unexpected-exit race for the watchdog in `run`. + if children() + .insert_async(actor_id.clone(), child.clone()) + .await + .is_err() + { + child.stop(cfg.stop_grace).await; + anyhow::bail!("a child for actor {actor_id} is already registered"); + } + *self.child.lock().await = Some(child); + Ok(()) + } + + /// Watchdog: waits for the child to exit. Deliberate stops remove the + /// child from the global registry first, so winning the `remove` race + /// means the exit was unexpected and the actor must be torn down. + async fn run(self: Arc, ctx: Ctx) -> Result<()> { + let Some(child) = self.child.lock().await.clone() else { + anyhow::bail!("run: child process was never spawned"); + }; + + let status = child.wait_exit().await; + + let actor_id = ctx.actor_id().to_string(); + if children().remove_async(&actor_id).await.is_some() { + let prefix = log_prefix(&actor_id, child.key.as_deref()); + println!( + "{prefix} runner: child exited unexpectedly ({status}), reporting actor stopped" + ); + // TODO: `ctx.destroy()` reports a clean stop, while the old envoy + // path reported an errored stop that fed the engine's crash + // policy. The rivetkit wrapper has no errored-stop API yet. + if let Err(err) = ctx.destroy() { + // The actor may already be stopping if the engine beat us to it. + tracing::debug!(error = ?err, actorId = %actor_id, "destroy after child exit failed"); + } + } + Ok(()) + } + + async fn on_fetch(self: Arc, ctx: Ctx, req: Request) -> Result { + let child_port = self + .child + .lock() + .await + .as_ref() + .map(|child| child.child_port) + .with_context(|| format!("fetch: no running child for actor {}", ctx.actor_id()))?; + crate::proxy::http_proxy(child_port, req).await + } + + async fn on_websocket( + self: Arc, + ctx: Ctx, + ws: WebSocket, + req: Request, + ) -> Result<()> { + let child_port = self + .child + .lock() + .await + .as_ref() + .map(|child| child.child_port) + .with_context(|| format!("websocket: no running child for actor {}", ctx.actor_id()))?; + let path = req + .uri() + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| "/".to_string()); + crate::proxy::ws_proxy(child_port, path, ws).await + } + + async fn on_destroy(self: Arc, ctx: Ctx) -> Result<()> { + let actor_id = ctx.actor_id().to_string(); + // Remove from the registry FIRST so the watchdog treats the exit as + // deliberate, then stop. `stop` is idempotent if the process shutdown + // sweep already stopped this child. + children().remove_async(&actor_id).await; + let child = self.child.lock().await.take(); + if let Some(child) = child { + child.stop(runner_config().stop_grace).await; + } + + // One actor per container: once the actor is gone this instance must + // not be reused for the next one. + request_exit(&actor_id, "actor stopped"); + Ok(()) + } +} + +fn actor_key_string(ctx: &Ctx) -> Option { + let key = ctx.key(); + if key.is_empty() { + None + } else { + Some( + key.iter() + .map(|segment| match segment { + ActorKeySegment::String(value) => value.clone(), + ActorKeySegment::Number(value) => value.to_string(), + }) + .collect::>() + .join(","), + ) + } +} diff --git a/container-runner/src/callbacks.rs b/container-runner/src/callbacks.rs deleted file mode 100644 index 20cb4ba93d..0000000000 --- a/container-runner/src/callbacks.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! `EnvoyCallbacks` implementation: the extension point Rivet calls to start/stop -//! the actor and to route tunneled traffic. Here the "actor" is a child game-server -//! process; traffic is proxied to its local port. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::Mutex as TokioMutex; - -use rivet_envoy_client::config::{ - BoxFuture, EnvoyCallbacks, HttpRequest, HttpResponse, WebSocketHandler, WebSocketSender, -}; -use rivet_envoy_client::handle::EnvoyHandle; -use rivet_envoy_client::protocol; - -use crate::child::{ChildProcess, SpawnSpec, log_prefix}; -use crate::input::ActorInput; - -/// Static runner configuration derived from the CLI/env. -pub struct RunnerConfig { - /// Child command template (program + fixed args) from `-- `. - pub command_template: Vec, - /// Default local port for the child when `input.port` is absent. - pub default_child_port: u16, - /// SIGTERM→SIGKILL grace period on stop. - pub stop_grace: Duration, - /// How long to wait for the child's port to open before failing the start. - pub readiness_timeout: Duration, -} - -/// Shared across the serverless runtime and the envoy client. -pub struct ContainerRunnerCallbacks { - cfg: Arc, - /// Running children keyed by actor id. Concurrency is 1 in the Cloud Run model, - /// but a map keeps the lifecycle explicit and race-free. - actors: Arc>>>, -} - -impl ContainerRunnerCallbacks { - pub fn new(cfg: Arc) -> Self { - Self { - cfg, - actors: Arc::new(TokioMutex::new(HashMap::new())), - } - } - - /// Stop every running child. Called on container-runner shutdown so children are - /// never orphaned (belt-and-suspenders alongside the child's `kill_on_drop`). - pub async fn stop_all(&self, grace: Duration) { - let children: Vec> = - self.actors.lock().await.drain().map(|(_, c)| c).collect(); - if children.is_empty() { - return; - } - println!("runner: shutdown, stopping {} child(ren)", children.len()); - for child in children { - child.stop(grace).await; - } - } -} - -impl EnvoyCallbacks for ContainerRunnerCallbacks { - fn on_actor_start( - &self, - handle: EnvoyHandle, - actor_id: String, - generation: u32, - config: protocol::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { - let cfg = self.cfg.clone(); - let actors = self.actors.clone(); - Box::pin(async move { - // One actor per container (Cloud Run 1:1 model). Guard against two failure modes - // that otherwise collide on the fixed child port: - // - A duplicate start for the SAME actor (engine retry) -> idempotent no-op. - // - A DIFFERENT actor landing on this container -> refuse loudly with an - // actionable message instead of spawning a second game server that can't bind. - { - let guard = actors.lock().await; - if let Some(existing) = guard.get(&actor_id) { - if !existing.has_exited() { - println!( - "{} runner: actor already running, ignoring duplicate start", - log_prefix(&actor_id, config.key.as_deref()) - ); - return Ok(()); - } - } - if let Some(other) = guard - .iter() - .find(|(id, c)| id.as_str() != actor_id.as_str() && !c.has_exited()) - .map(|(id, _)| id.clone()) - { - anyhow::bail!( - "container-runner hosts one actor per container (Cloud Run 1:1 model), \ - but actor {other} is already running; refusing to start {actor_id}. \ - Configure the serverless runner with max_concurrent_actors=1 and \ - Cloud Run request concurrency=1." - ); - } - } - - let input = ActorInput::parse(config.input.as_deref())?; - let child_port = input.port.unwrap_or(cfg.default_child_port); - - // input.command overrides the CLI template; input.args are appended. - let mut parts = input - .command - .clone() - .unwrap_or_else(|| cfg.command_template.clone()); - if parts.is_empty() { - anyhow::bail!( - "no child command: CLI template is empty and input.command was not provided" - ); - } - parts.extend(input.args.clone()); - let program = parts.remove(0); - - let spec = SpawnSpec { - program, - args: parts, - env: input.env, - child_port, - actor_id: actor_id.clone(), - key: config.key.clone(), - }; - - tracing::info!( - boot_id = crate::inspector::boot_id(), - actorId = %actor_id, - "actor starting on this container instance" - ); - - let child = Arc::new(ChildProcess::spawn(spec, cfg.readiness_timeout).await?); - - // Watchdog: if the child exits unexpectedly (not via on_actor_stop), tell - // the engine to stop the actor. `remove` returns `Some` only if we won the - // race against a deliberate stop, avoiding a double report. - { - let child = child.clone(); - let actors = actors.clone(); - let handle = handle.clone(); - let actor_id = actor_id.clone(); - tokio::spawn(async move { - let status = child.wait_exit().await; - // `remove` returns Some only if we won the race against a deliberate - // stop (on_actor_stop) or a runtime shutdown (stop_all) — i.e. this was - // an UNEXPECTED child exit. Report it so the engine applies crash policy. - if actors.lock().await.remove(&actor_id).is_some() { - let prefix = log_prefix(&actor_id, child.key.as_deref()); - println!( - "{prefix} runner: child exited unexpectedly ({status}), reporting actor stopped" - ); - handle.stop_actor( - actor_id, - Some(generation), - Some(format!("child exited: {status}")), - ); - } - }); - } - - // Ensure the inspector bearer token exists in KV key [0x03] so the dashboard - if let Err(e) = crate::inspector::ensure_inspector_token(&handle, &actor_id).await { - tracing::warn!(error = ?e, actorId = %actor_id, "failed to ensure inspector token"); - } - - actors.lock().await.insert(actor_id, child); - Ok(()) - }) - } - - fn on_actor_stop( - &self, - _handle: EnvoyHandle, - actor_id: String, - _generation: u32, - _reason: protocol::StopActorReason, - ) -> BoxFuture> { - let actors = self.actors.clone(); - let grace = self.cfg.stop_grace; - Box::pin(async move { - let child = actors.lock().await.remove(&actor_id); - if let Some(child) = child { - child.stop(grace).await; - } - Ok(()) - }) - } - - fn on_shutdown(&self) { - // Envoy-driven shutdown hook (sync). Actual child teardown is the async - // `stop_all`, invoked from main's shutdown path. - tracing::info!("envoy shutdown"); - } - - fn fetch( - &self, - _handle: EnvoyHandle, - actor_id: String, - _gateway_id: protocol::GatewayId, - _request_id: protocol::RequestId, - request: HttpRequest, - ) -> BoxFuture> { - let actors = self.actors.clone(); - Box::pin(async move { - // Serve the custom inspector UI directly, BEFORE proxying to the child. Only - // GET requests under `/inspector/ui/` are intercepted. - if request.method.eq_ignore_ascii_case("GET") - && crate::inspector::is_inspector_ui_path(&request.path) - { - return Ok(crate::inspector::inspector_response()); - } - - // The dashboard polls `/metadata` on the actor surface. The child can't answer - // it (WebSocket-only), so answer it here. - if request.method.eq_ignore_ascii_case("GET") - && crate::inspector::is_metadata_path(&request.path) - { - return Ok(crate::inspector::metadata_response()); - } - - let Some(child_port) = actors.lock().await.get(&actor_id).map(|c| c.child_port) else { - anyhow::bail!("fetch: no running child for actor {actor_id}"); - }; - crate::proxy::http_proxy(child_port, request).await - }) - } - - fn websocket( - &self, - _handle: EnvoyHandle, - actor_id: String, - _gateway_id: protocol::GatewayId, - _request_id: protocol::RequestId, - _request: HttpRequest, - path: String, - _headers: HashMap, - _is_hibernatable: bool, - _is_restoring_hibernatable: bool, - _sender: WebSocketSender, - ) -> BoxFuture> { - let actors = self.actors.clone(); - Box::pin(async move { - let Some(child_port) = actors.lock().await.get(&actor_id).map(|c| c.child_port) else { - anyhow::bail!("websocket: no running child for actor {actor_id}"); - }; - crate::proxy::ws_proxy(child_port, path).await - }) - } - - fn can_hibernate( - &self, - _actor_id: &str, - _gateway_id: &protocol::GatewayId, - _request_id: &protocol::RequestId, - _request: &HttpRequest, - ) -> BoxFuture> { - // Game servers hold live in-memory state; never hibernate a connection. - Box::pin(async { Ok(false) }) - } -} diff --git a/container-runner/src/input.rs b/container-runner/src/input.rs index e2d67fcfda..49ef7fa6ca 100644 --- a/container-runner/src/input.rs +++ b/container-runner/src/input.rs @@ -1,17 +1,17 @@ -//! Decoding of the opaque `ActorConfig.input` bytes into a concrete child-process -//! launch spec. +//! The actor input payload describing how to launch the child game server. //! -//! The Rivet runner protocol only carries an actor's `name`, `key`, `create_ts` -//! and an opaque `input: Option>`. Everything the game server needs to -//! launch (command, args, env, port) is therefore JSON-encoded by the client into -//! `input` and decoded here. All fields are optional; anything omitted falls back +//! Everything the game server needs to launch (command, args, env, port) is +//! carried in the actor's create-time `input` payload, CBOR-encoded per the +//! RivetKit convention. All fields are optional; anything omitted falls back //! to the CLI-provided template (`rivet-container-runner -- `). +//! The decoded input is also the actor's persisted state so a woken actor +//! restores the same launch spec without re-decoding input. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -/// Shape of the JSON carried in `ActorConfig.input`. -#[derive(Debug, Default, Deserialize)] +/// Shape of the actor `input` payload. +#[derive(Debug, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ActorInput { /// Overrides the CLI command template entirely (program + fixed args). @@ -29,14 +29,6 @@ pub struct ActorInput { pub port: Option, } -impl ActorInput { - /// Parse the opaque input bytes. An absent/empty payload yields defaults. - pub fn parse(input: Option<&[u8]>) -> anyhow::Result { - match input { - None => Ok(Self::default()), - Some(bytes) if bytes.is_empty() => Ok(Self::default()), - Some(bytes) => serde_json::from_slice(bytes) - .map_err(|e| anyhow::anyhow!("failed to parse actor input as JSON: {e}")), - } - } -} +#[cfg(test)] +#[path = "../tests/inline/input.rs"] +mod tests; diff --git a/container-runner/src/inspector.rs b/container-runner/src/inspector.rs deleted file mode 100644 index 1130359522..0000000000 --- a/container-runner/src/inspector.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Custom Rivet actor inspector page, served directly by the container-runner. -//! -//! Two responsibilities: -//! 1. On actor start, ensure an inspector bearer token exists in KV key `[0x03]` -//! (base64 `"Aw=="`). The dashboard reads this key BEFORE mounting the inspector -//! iframe; if it is absent the inspector panel stays blank. -//! 2. Serve a minimal "Hello World" HTML page for `GET /inspector/ui/...`, intercepted -//! in `callbacks::fetch` before the request would otherwise be proxied to the child. -//! -//! This intentionally does NOT implement the inspector WebSocket / BARE protocol. - -use std::collections::HashMap; -use std::io::Read; - -use rivet_envoy_client::config::HttpResponse; -use rivet_envoy_client::handle::EnvoyHandle; - -const INSPECTOR_TOKEN_KEY: &[u8] = &[0x03]; -const INSPECTOR_UI_PREFIX: &str = "/inspector/ui/"; - -/// True if a request path should be served by our inspector UI instead of proxied to -/// the child. -pub fn is_inspector_ui_path(path: &str) -> bool { - // Ignore any query string when matching. - let path = path.split(['?', '#']).next().unwrap_or(path); - path.starts_with(INSPECTOR_UI_PREFIX) -} - -/// Custom inspector page. It MUST postMessage `{type:"ready"}` to the parent on -/// load, otherwise the dashboard keeps the iframe hidden and errors out after ~8s. -/// It optionally listens for the host's `{type:"init", v:1, auth:}` message and -/// shows the token — purely informational, safe to ignore. -const INSPECTOR_HTML: &str = r#" - - - - -Actor Inspector - - - -

Hello World

-

waiting for host init…

- - - -"#; - -/// Build the inspector HTML response. Always `200 text/html`. -pub fn inspector_response() -> HttpResponse { - let mut headers = HashMap::new(); - headers.insert( - "content-type".to_string(), - "text/html; charset=utf-8".to_string(), - ); - HttpResponse { - status: 200, - headers, - body: Some(INSPECTOR_HTML.as_bytes().to_vec()), - body_stream: None, - } -} - -/// `GET /metadata` on the actor surface. The dashboard polls this; without a handler it -/// falls through to the child proxy. -pub fn is_metadata_path(path: &str) -> bool { - let path = path.split('?').next().unwrap_or(path); - path == "/metadata" || path == "/metadata/" -} - -/// Mirrors rivetkit's actor-surface handler (`registry/http.rs: handle_metadata_fetch`). -pub fn metadata_response() -> HttpResponse { - let mut headers = HashMap::new(); - headers.insert("content-type".to_string(), "application/json".to_string()); - let body = br#"{"runtime":"rivetkit","version":"2.3.0"}"#.to_vec(); - HttpResponse { - status: 200, - headers, - body: Some(body), - body_stream: None, - } -} - -/// Stable per-process identifier, generated once. The runner is PID 1 in the image, so -/// one boot id == one container instance. Logged at startup and on every actor start so -/// an actor can be attributed to an instance (the log stream carries no instance id). -pub fn boot_id() -> &'static str { - static BOOT_ID: std::sync::OnceLock = std::sync::OnceLock::new(); - BOOT_ID.get_or_init(|| { - // 9 random bytes -> 12 chars. Falls back to a fixed marker if the CSPRNG is - // unavailable, which would itself be worth seeing in the logs. - let mut buf = [0u8; 9]; - match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf)) { - Ok(()) => base64url_nopad(&buf), - Err(_) => "no-urandom".to_string(), - } - }) -} - -/// Generate a URL-safe base64 (no padding) token from ~32 random bytes. -fn generate_token() -> anyhow::Result { - let mut buf = [0u8; 32]; - // No `rand`/`getrandom` in this crate's dep tree; read the OS CSPRNG directly. - // This runner only ever runs on Linux (Cloud Run), where /dev/urandom is present. - let mut f = std::fs::File::open("/dev/urandom")?; - f.read_exact(&mut buf)?; - Ok(base64url_nopad(&buf)) -} - -/// Minimal URL-safe base64 encoder (RFC 4648 §5) without padding. Kept local to avoid -/// adding a dependency just for the inspector token. -fn base64url_nopad(input: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut out = String::with_capacity(input.len().div_ceil(3) * 4); - for chunk in input.chunks(3) { - let b0 = chunk[0] as u32; - let b1 = *chunk.get(1).unwrap_or(&0) as u32; - let b2 = *chunk.get(2).unwrap_or(&0) as u32; - let n = (b0 << 16) | (b1 << 8) | b2; - out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); - out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); - if chunk.len() > 1 { - out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char); - } - if chunk.len() > 2 { - out.push(ALPHABET[(n & 0x3f) as usize] as char); - } - } - out -} - -pub async fn ensure_inspector_token(handle: &EnvoyHandle, actor_id: &str) -> anyhow::Result<()> { - let existing = handle - .kv_get(actor_id.to_string(), vec![INSPECTOR_TOKEN_KEY.to_vec()]) - .await?; - - if matches!(existing.first(), Some(Some(_))) { - // Token already present — leave it untouched so we don't rotate on restart. - return Ok(()); - } - - let token = generate_token()?; - handle - .kv_put( - actor_id.to_string(), - vec![(INSPECTOR_TOKEN_KEY.to_vec(), token.into_bytes())], - ) - .await?; - tracing::info!(actorId = %actor_id, "wrote inspector token to KV key [0x03]"); - Ok(()) -} - -#[cfg(test)] -#[path = "../tests/inline/inspector.rs"] -mod tests; diff --git a/container-runner/src/main.rs b/container-runner/src/main.rs index 7f94c7306b..ce64fd3807 100644 --- a/container-runner/src/main.rs +++ b/container-runner/src/main.rs @@ -1,31 +1,118 @@ -//! `rivet-container-runner` — a Rivet serverless runner that hosts a single actor by -//! spawning a child game-server process and proxying Rivet's tunneled traffic to it. +//! `rivet-container-runner` — a RivetKit serverless app that hosts a single +//! actor by spawning a child game-server process and proxying Rivet's +//! tunneled traffic to it. //! -//! Serverless model: the engine's `POST /api/rivet/start` starts this container. On -//! actor start we spawn the child (`-- `), pipe its logs to stdout prefixed -//! with the actor id + key, and proxy inbound HTTP/WebSocket (arriving over Rivet's -//! tunnel) to the child's local port. On actor stop we SIGTERM the child. +//! Serverless model: the engine's `POST /api/rivet/start` starts this +//! container (served by rivetkit-core's serverless runtime). On actor start +//! the `GameServer` actor spawns the child (`-- `), pipes its +//! logs to stdout prefixed with the actor id + key, and proxies inbound +//! HTTP/WebSocket (arriving over Rivet's tunnel) to the child's local port. +//! On actor stop it SIGTERMs the child and exits the process, so one +//! container instance serves exactly one actor. -mod callbacks; +mod actor; mod child; mod input; -mod inspector; mod proxy; -mod serverless; -use std::sync::Arc; +use std::io::Read; +use std::sync::{Arc, LazyLock, OnceLock}; use std::time::Duration; use anyhow::Result; use clap::Parser; +use rivetkit::serverless_http::{self, ListenerConfig}; +use rivetkit::{ActorConfig, EngineSpawnMode, Registry, ServeConfig}; use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; -use crate::callbacks::{ContainerRunnerCallbacks, RunnerConfig}; -use crate::serverless::{ServerlessRuntime, ServerlessSettings}; +use crate::actor::GameServer; +use crate::child::ChildProcess; -/// Default `/start` payload cap, matching the engine's default `maxStartPayloadBytes`. -const MAX_START_PAYLOAD_BYTES: usize = 1024 * 1024; +/// Static runner configuration derived from the CLI/env. +pub struct RunnerConfig { + /// Child command template (program + fixed args) from `-- `. + pub command_template: Vec, + /// Default local port for the child when `input.port` is absent. + pub default_child_port: u16, + /// SIGTERM→SIGKILL grace period on stop. + pub stop_grace: Duration, + /// How long to wait for the child's port to open before failing the start. + pub readiness_timeout: Duration, +} + +// The `Actor` trait constructs actors without user parameters, so the runner +// configuration is ambient process state set once in `main`. +static RUNNER_CONFIG: OnceLock> = OnceLock::new(); + +/// Running children keyed by actor id. Owned globally (not only by actors) so +/// the process shutdown path can stop children even if actor hooks never run, +/// and so the watchdog and stop paths can arbitrate who reports an exit. +static CHILDREN: LazyLock>> = + LazyLock::new(scc::HashMap::new); + +/// Cancelled to bring the whole process down (actor stopped, failed start, or +/// signal). `main` owns the exit sequencing. +static EXIT: LazyLock = LazyLock::new(CancellationToken::new); + +pub fn runner_config() -> Arc { + RUNNER_CONFIG + .get() + .expect("runner config is set in main before the runtime serves") + .clone() +} + +pub fn children() -> &'static scc::HashMap> { + &CHILDREN +} + +/// End the process. This container hosts exactly one actor for its lifetime; +/// when that actor is gone (or the instance is poisoned by a failed start) it +/// must not be reused for the next actor. The runner is PID 1 in the image, +/// so exiting stops the container and the platform reaps the instance. +pub fn request_exit(actor_id: &str, reason: &str) { + tracing::info!(actorId = %actor_id, reason, "actor finished, exiting container"); + EXIT.cancel(); +} + +/// Stable per-process identifier, generated once. The runner is PID 1 in the +/// image, so one boot id == one container instance. Logged at startup and on +/// every actor start so an actor can be attributed to an instance (the log +/// stream carries no instance id). +pub fn boot_id() -> &'static str { + static BOOT_ID: OnceLock = OnceLock::new(); + BOOT_ID.get_or_init(|| { + // 9 random bytes -> 12 chars. Falls back to a fixed marker if the + // CSPRNG is unavailable, which would itself be worth seeing in logs. + let mut buf = [0u8; 9]; + match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf)) { + Ok(()) => base64url_nopad(&buf), + Err(_) => "no-urandom".to_string(), + } + }) +} + +/// Minimal URL-safe base64 encoder (RFC 4648 §5) without padding. Kept local +/// to avoid adding a dependency just for the boot id. +fn base64url_nopad(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char); + } + if chunk.len() > 2 { + out.push(ALPHABET[(n & 0x3f) as usize] as char); + } + } + out +} #[derive(Parser, Debug)] #[command( @@ -69,8 +156,8 @@ struct Args { } fn main() -> Result<()> { - // Rivet Compute documents RIVET_PORT for RivetKit apps. This runner is not a - // standard RivetKit app, but accepting it makes the container friendlier there. + // Rivet Compute documents RIVET_PORT for RivetKit apps; accept it for the + // front-door port resolution below. if std::env::var_os("PORT").is_none() { if let Some(port) = std::env::var_os("RIVET_PORT") { // SAFETY: no other threads exist yet; the tokio runtime starts below. @@ -94,10 +181,7 @@ async fn async_main() -> Result<()> { .init(); let args = Args::parse(); - // Identifies this process (hence this container instance) across the log stream. - // The logs carry no instance id, so this is how we tell "a new instance served the - // next actor" apart from "the old instance was reused". - let boot_id = crate::inspector::boot_id(); + let boot_id = boot_id(); tracing::info!(?args, %boot_id, "starting container-runner"); // Front-door port: Rivet Compute injects RIVET_PORT; plain Cloud Run uses PORT. @@ -107,42 +191,68 @@ async fn async_main() -> Result<()> { .or_else(|| env_u16("PORT")) .unwrap_or(8080); - let runner_cfg = Arc::new(RunnerConfig { - command_template: args.command.clone(), - default_child_port: args.child_port, - stop_grace: Duration::from_secs(args.stop_grace_secs), - readiness_timeout: Duration::from_secs(args.readiness_timeout_secs), - }); + let stop_grace = Duration::from_secs(args.stop_grace_secs); + RUNNER_CONFIG + .set(Arc::new(RunnerConfig { + command_template: args.command.clone(), + default_child_port: args.child_port, + stop_grace, + readiness_timeout: Duration::from_secs(args.readiness_timeout_secs), + })) + .map_err(|_| anyhow::anyhow!("runner config already set"))?; - let callbacks = Arc::new(ContainerRunnerCallbacks::new(runner_cfg)); - let shutdown_callbacks = callbacks.clone(); + let mut registry = Registry::new(); + registry.register_actor_with::( + &args.actor_name, + ActorConfig { + // Game servers hold live in-memory state; never idle-sleep the actor. + no_sleep: true, + // The destroy grace deadline must outlast the child's SIGTERM→SIGKILL + // window or core aborts `on_destroy` mid-stop and leaks the child. + sleep_grace_period: stop_grace + Duration::from_secs(10), + sleep_grace_period_overridden: true, + ..Default::default() + }, + ); - let settings = ServerlessSettings { - version: args.runner_version, - base_path: args.base_path.clone(), - actor_names: vec![args.actor_name.clone()], - max_start_payload_bytes: MAX_START_PAYLOAD_BYTES, - }; + let mut config = ServeConfig::from_env(); + config.version = args.runner_version; + config.serverless_base_path = Some(args.base_path.clone()); + // The engine passes its endpoint per /start request in headers; never + // spawn a local engine, and don't reject starts whose header endpoint + // differs from the env-default one. + config.engine_spawn = EngineSpawnMode::Never; + config.serverless_validate_endpoint = false; - let shutdown = CancellationToken::new(); - spawn_signal_handler(shutdown.clone()); + let runtime = registry.into_serverless_runtime(config).await?; - let rt = ServerlessRuntime::new(settings, callbacks, shutdown.clone()); + let serve_shutdown = CancellationToken::new(); + spawn_signal_handler(); - let serve_rt = rt.clone(); - let serve_shutdown = shutdown.clone(); - let server = - tokio::spawn(async move { serverless::serve(serve_rt, port, serve_shutdown).await }); + let serve = tokio::spawn(serverless_http::serve( + runtime.clone(), + ListenerConfig { + // Bind dual-stack ([::]) so the front door accepts both IPv4 (as + // IPv4-mapped) and IPv6 loopback: the engine's metadata client may + // connect via `localhost`/::1. + host: Some("::".to_string()), + port, + public_dir: None, + }, + serve_shutdown.clone(), + )); + tracing::info!(port, "container-runner serverless front door listening"); - // Wait for shutdown, then stop children and drain the envoy. - shutdown.cancelled().await; - tracing::info!("shutdown requested, stopping children + draining"); - shutdown_callbacks - .stop_all(Duration::from_secs(args.stop_grace_secs)) - .await; - rt.shutdown().await; + // Wait for an exit request (actor stopped, failed start, or signal), then + // tear down: stop any children the actor hooks didn't reap, drain the + // runtime (stops actors and flushes the /start SSE stopping frame + the + // cached envoy), and only then close the listener. + EXIT.cancelled().await; + stop_all_children(stop_grace).await; + runtime.shutdown().await; + serve_shutdown.cancel(); - match server.await { + match serve.await { Ok(Ok(())) => {} Ok(Err(e)) => tracing::error!(error = ?e, "server error"), Err(e) => tracing::error!(error = ?e, "server task join error"), @@ -152,11 +262,31 @@ async fn async_main() -> Result<()> { Ok(()) } +/// 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 +/// path so children are never orphaned. +async fn stop_all_children(grace: Duration) { + let mut children: Vec> = Vec::new(); + CHILDREN + .retain_async(|_, child| { + children.push(child.clone()); + false + }) + .await; + if children.is_empty() { + return; + } + println!("runner: shutdown, stopping {} child(ren)", children.len()); + for child in children { + child.stop(grace).await; + } +} + fn env_u16(key: &str) -> Option { std::env::var(key).ok().and_then(|s| s.parse().ok()) } -fn spawn_signal_handler(shutdown: CancellationToken) { +fn spawn_signal_handler() { tokio::spawn(async move { use tokio::signal::unix::{SignalKind, signal}; let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler"); @@ -165,6 +295,10 @@ fn spawn_signal_handler(shutdown: CancellationToken) { _ = sigterm.recv() => tracing::info!("received SIGTERM"), _ = sigint.recv() => tracing::info!("received SIGINT"), } - shutdown.cancel(); + request_exit("-", "signal"); }); } + +#[cfg(test)] +#[path = "../tests/inline/boot_id.rs"] +mod tests; diff --git a/container-runner/src/proxy.rs b/container-runner/src/proxy.rs index 23803ee214..91993bbeac 100644 --- a/container-runner/src/proxy.rs +++ b/container-runner/src/proxy.rs @@ -1,34 +1,34 @@ //! Bridges Rivet's decoded tunnel traffic to the child game server's local port. //! -//! The Rivet SDK reassembles tunnel frames into decoded `HttpRequest`s and -//! `WebSocketMessage` streams (see `EnvoyCallbacks::fetch` / `::websocket`). This -//! module forwards those to `127.0.0.1:` and back — the ~200 lines of -//! glue the SDK deliberately leaves to the host. +//! The rivetkit runtime reassembles tunnel frames into decoded `Request`s and +//! `WebSocket` streams (see `Actor::on_fetch` / `::on_websocket`). This module +//! forwards those to `127.0.0.1:` and back — the glue the runtime +//! deliberately leaves to the actor. use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use futures_util::{SinkExt, StreamExt}; -use tokio::sync::Mutex as TokioMutex; +use tokio::sync::{Mutex as TokioMutex, mpsc}; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; -use rivet_envoy_client::config::{ - BoxFuture, HttpRequest, HttpResponse, WebSocketHandler, WebSocketMessage, WebSocketSender, -}; +use rivetkit::{Request, Response, WebSocket, WsMessage}; /// Proxy a decoded inbound HTTP request to the child and map the response back. -pub async fn http_proxy(child_port: u16, req: HttpRequest) -> Result { - let HttpRequest { - method, - path, - headers, - body, - body_stream: _, - } = req; +/// +/// The rivetkit actor surface delivers `/request/*` paths to `on_fetch` with +/// the prefix already stripped, so `uri` here is the child-relative path. +pub async fn http_proxy(child_port: u16, req: Request) -> Result { + let (method, uri, headers, body) = req.to_parts(); + let path = if uri.starts_with('/') { + uri + } else { + format!("/{uri}") + }; let url = format!("http://127.0.0.1:{child_port}{path}"); let method = reqwest::Method::from_bytes(method.as_bytes())?; @@ -41,7 +41,7 @@ pub async fn http_proxy(child_port: u16, req: HttpRequest) -> Result Result child traffic forwarded off the runtime's event callbacks. +enum ClientEvent { + Message(WsMessage), + Close(u16, String), } -/// Dial the child's WebSocket endpoint and return a handler that pumps frames in -/// both directions: child -> Rivet client (via `on_open`) and client -> child -/// (via `on_message` / `on_close`). -pub async fn ws_proxy(child_port: u16, path: String) -> Result { +/// Dial the child's WebSocket endpoint and pump frames in both directions: +/// child -> Rivet client via `ws.send`, and client -> child via the +/// WebSocket's message/close event callbacks. +pub async fn ws_proxy(child_port: u16, path: String, ws: WebSocket) -> Result<()> { let url = format!("ws://127.0.0.1:{child_port}{path}"); - let (ws, _resp) = tokio_tungstenite::connect_async(&url).await?; - let (sink, mut stream) = ws.split(); + let (child_ws, _resp) = tokio_tungstenite::connect_async(&url).await?; + let (sink, mut stream) = child_ws.split(); let sink = Arc::new(TokioMutex::new(sink)); - // child -> client pump, started once the tunnel side opens. - let on_open: Box BoxFuture<()> + Send> = - Box::new(move |client: WebSocketSender| { - Box::pin(async move { - tokio::spawn(async move { - while let Some(msg) = stream.next().await { - match msg { - Ok(Message::Binary(b)) => client.send(b.to_vec(), true), - Ok(Message::Text(t)) => client.send_text(t.as_str()), - Ok(Message::Close(cf)) => { - let (code, reason) = match cf { - Some(c) => { - (Some(u16::from(c.code)), Some(c.reason.to_string())) - } - None => (None, None), - }; - client.close(code, reason); - break; - } - Ok(_) => {} // Ping/Pong/Frame handled by tungstenite - Err(e) => { - client.close(Some(1011), Some(format!("child ws error: {e}"))); - break; - } + // client -> child. The message event callback is synchronous and invoked + // from the runtime's envoy pump, so it must never block: enqueue and let a + // spawned task own the child sink. + let (tx, mut rx) = mpsc::unbounded_channel::(); + { + let tx = tx.clone(); + ws.configure_message_event_callback(Some(Arc::new(move |msg, _message_index| { + tx.send(ClientEvent::Message(msg)) + .map_err(|_| anyhow::anyhow!("client->child pump ended")) + }))); + } + ws.configure_close_event_callback(Some(Arc::new(move |code, reason, _was_clean| { + let tx = tx.clone(); + Box::pin(async move { + tx.send(ClientEvent::Close(code, reason)) + .map_err(|_| anyhow::anyhow!("client->child pump ended")) + }) + }))); + + { + let sink = sink.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + match event { + ClientEvent::Message(msg) => { + let frame = match msg { + WsMessage::Binary(b) => Message::Binary(b.into()), + WsMessage::Text(t) => Message::Text(t.into()), + }; + let mut s = sink.lock().await; + if s.send(frame).await.is_err() { + break; } } - }); - }) + ClientEvent::Close(code, reason) => { + let frame = Message::Close(Some(CloseFrame { + code: CloseCode::from(code), + reason: reason.into(), + })); + let mut s = sink.lock().await; + let _ = s.send(frame).await; + let _ = s.close().await; + break; + } + } + } }); + } - // client -> child - let on_message: Box BoxFuture<()> + Send + Sync> = { - let sink = sink.clone(); - Box::new(move |msg: WebSocketMessage| { - let sink = sink.clone(); - Box::pin(async move { - let frame = if msg.binary { - Message::Binary(msg.data.into()) - } else { - Message::Text(String::from_utf8_lossy(&msg.data).into_owned().into()) - }; - let mut s = sink.lock().await; - let _ = s.send(frame).await; - }) - }) - }; - - let on_close: Box BoxFuture<()> + Send + Sync> = { - let sink = sink.clone(); - Box::new(move |code: u16, reason: String| { - let sink = sink.clone(); - Box::pin(async move { - let frame = Message::Close(Some(CloseFrame { - code: CloseCode::from(code), - reason: reason.into(), - })); - let mut s = sink.lock().await; - let _ = s.send(frame).await; - let _ = s.close().await; - }) - }) - }; + // child -> client pump. + tokio::spawn(async move { + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Binary(b)) => ws.send(WsMessage::Binary(b.to_vec())), + Ok(Message::Text(t)) => ws.send(WsMessage::Text(t.as_str().to_string())), + Ok(Message::Close(cf)) => { + let (code, reason) = match cf { + Some(c) => (Some(u16::from(c.code)), Some(c.reason.to_string())), + None => (None, None), + }; + ws.close(code, reason).await; + break; + } + // Ping/Pong/Frame handled by tungstenite. + Ok(_) => {} + Err(e) => { + ws.close(Some(1011), Some(format!("child ws error: {e}"))) + .await; + break; + } + } + } + }); - Ok(WebSocketHandler { - on_message, - on_close, - on_open: Some(on_open), - }) + Ok(()) } diff --git a/container-runner/src/serverless.rs b/container-runner/src/serverless.rs deleted file mode 100644 index 128dabb5ee..0000000000 --- a/container-runner/src/serverless.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! The serverless HTTP front door. -//! -//! Cloud Run routes the engine's outbound `POST /api/rivet/start` to this server; -//! that request holds the container open for the actor's lifetime (SSE response). -//! This is a slimmed re-implementation of `rivetkit-core`'s `serverless.rs` + -//! `serverless_http.rs`, wired to our own `ContainerRunnerCallbacks` (the upstream -//! `CoreServerlessRuntime` is hard-coded to its in-process actor registry). - -use std::collections::HashMap; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use anyhow::{Context, Result}; -use axum::Router; -use axum::body::{Body, Bytes}; -use axum::extract::{Request, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::routing::any; -use futures_util::StreamExt; -use serde_json::json; -use tokio::net::TcpListener; -use tokio::sync::{Mutex as TokioMutex, mpsc}; -use tokio_stream::wrappers::UnboundedReceiverStream; -use tokio_util::sync::CancellationToken; - -use rivet_envoy_client::config::{ActorName, EnvoyConfig}; -use rivet_envoy_client::envoy::start_envoy; -use rivet_envoy_client::handle::EnvoyHandle; -use rivet_envoy_client::protocol; - -use crate::callbacks::ContainerRunnerCallbacks; - -const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); -const SSE_PING_INTERVAL: Duration = Duration::from_secs(1); -const SSE_PING_FRAME: &[u8] = b"event: ping\ndata:\n\n"; -const SSE_STOPPING_FRAME: &[u8] = b"event: stopping\ndata:\n\n"; - -/// Static settings for the serverless runtime. -pub struct ServerlessSettings { - /// Runner version reported to the engine (used for draining on deploy). - pub version: u32, - /// Base path the engine calls (default `/api/rivet`). - pub base_path: String, - /// Actor names this runner advertises. - pub actor_names: Vec, - /// Max `/start` payload size. - pub max_start_payload_bytes: usize, -} - -pub struct ServerlessRuntime { - settings: ServerlessSettings, - callbacks: Arc, - envoy: Arc>>, - shutting_down: AtomicBool, - /// Cancelled to bring the whole process down. `main` owns the other end. - exit: CancellationToken, -} - -impl ServerlessRuntime { - pub fn new( - settings: ServerlessSettings, - callbacks: Arc, - exit: CancellationToken, - ) -> Arc { - Arc::new(Self { - settings, - callbacks, - envoy: Arc::new(TokioMutex::new(None)), - shutting_down: AtomicBool::new(false), - exit, - }) - } - - fn prepopulate(&self) -> HashMap { - self.settings - .actor_names - .iter() - .map(|name| { - ( - name.clone(), - ActorName { - metadata: json!({}), - }, - ) - }) - .collect() - } - - fn metadata_json(&self) -> serde_json::Value { - let actor_names: serde_json::Map = self - .settings - .actor_names - .iter() - .map(|name| (name.clone(), json!({ "metadata": {} }))) - .collect(); - // The engine's serverless metadata validation only accepts runtime == "rivetkit" - // (engine `pegboard_serverless_metadata_fetch`). We speak the same envoy protocol, - // so we present as "rivetkit" to be accepted. - json!({ - "runtime": "rivetkit", - "version": PKG_VERSION, - "envoyProtocolVersion": protocol::PROTOCOL_VERSION, - "actorNames": actor_names, - "envoy": { "kind": { "serverless": {} }, "version": self.settings.version }, - }) - } - - /// Lazily start (and cache) the envoy client using our callbacks. - async fn ensure_envoy(&self, headers: &StartHeaders) -> Result { - if self.shutting_down.load(Ordering::Acquire) { - anyhow::bail!("runtime is shutting down"); - } - let mut guard = self.envoy.lock().await; - if let Some(handle) = guard.as_ref() { - return Ok(handle.clone()); - } - let callbacks: Arc = self.callbacks.clone(); - let handle = start_envoy(EnvoyConfig { - version: self.settings.version, - endpoint: headers.endpoint.clone(), - token: headers.token.clone(), - namespace: headers.namespace.clone(), - pool_name: headers.pool_name.clone(), - prepopulate_actor_names: self.prepopulate(), - metadata: Some(json!({ "container-runner": { "version": PKG_VERSION } })), - not_global: true, - debug_latency_ms: None, - callbacks, - }) - .await; - *guard = Some(handle.clone()); - Ok(handle) - } - - /// Drain the cached envoy on shutdown. - pub async fn shutdown(&self) { - self.shutting_down.store(true, Ordering::Release); - let handle = self.envoy.lock().await.take(); - if let Some(handle) = handle { - handle.shutdown_and_wait(false).await; - } - } - - /// End the process when an actor's `/start` ends. This container hosts exactly one - /// actor for its lifetime; when that actor is gone the instance must not be reused - /// for the next one. The runner is PID 1 in the image, so exiting stops the - /// container and the platform reaps the instance. - /// - /// Cancelling (rather than `process::exit`) lets `main` run the existing teardown: - /// SIGTERM the child, drain the envoy, and let axum's graceful shutdown flush the - /// in-flight `/start` SSE response before the socket closes. - pub fn request_exit(&self, actor_id: &str, reason: &str) { - tracing::info!(actorId = %actor_id, reason, "actor finished, exiting container"); - self.exit.cancel(); - } -} - -#[derive(Debug)] -struct StartHeaders { - endpoint: String, - token: Option, - pool_name: String, - namespace: String, -} - -fn header(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) -} - -fn parse_start_headers(headers: &HeaderMap) -> Result { - let pool_name = header(headers, "x-rivet-pool-name") - .or_else(|| header(headers, "x-rivet-runner-name")) - .context("x-rivet-pool-name header is required")?; - Ok(StartHeaders { - endpoint: header(headers, "x-rivet-endpoint") - .context("x-rivet-endpoint header is required")?, - token: header(headers, "x-rivet-token"), - pool_name, - namespace: header(headers, "x-rivet-namespace-name") - .context("x-rivet-namespace-name header is required")?, - }) -} - -/// Strip the configured base path from the request path. -fn route_path(base_path: &str, path: &str) -> String { - if path == base_path { - return String::new(); - } - let prefix = format!("{base_path}/"); - if let Some(rest) = path.strip_prefix(&prefix) { - return format!("/{rest}"); - } - path.to_string() -} - -/// Build the axum app. -pub fn router(rt: Arc) -> Router { - Router::new().fallback_service(any(forward).with_state(rt)) -} - -/// Bind and serve until `shutdown` fires. -pub async fn serve( - rt: Arc, - port: u16, - shutdown: CancellationToken, -) -> Result<()> { - // Bind dual-stack ([::]) so the front door accepts both IPv4 (as IPv4-mapped) and IPv6 - // loopback. The engine's metadata client may connect via `localhost`/::1; an IPv4-only - // 0.0.0.0 bind would silently miss those. - let addr = std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, port)); - let listener = TcpListener::bind(addr) - .await - .with_context(|| format!("bind serverless listener on [::]:{port}"))?; - tracing::info!(port, "container-runner serverless front door listening"); - let app = router(rt); - let shutdown_fut = async move { shutdown.cancelled().await }; - axum::serve(listener, app.into_make_service()) - .with_graceful_shutdown(shutdown_fut) - .await - .context("axum::serve error")?; - Ok(()) -} - -async fn forward(State(rt): State>, req: Request) -> Response { - let method = req.method().clone(); - let path = req.uri().path().to_string(); - tracing::info!(%method, %path, "front door received request"); - let subpath = route_path(&rt.settings.base_path, &path); - - match (method.as_str(), subpath.as_str()) { - ("GET", "") | ("GET", "/") => ( - StatusCode::OK, - "This is a container-runner (Rivet serverless runner).\n", - ) - .into_response(), - ("GET", "/health") => { - let healthy = { - let guard = rt.envoy.lock().await; - guard.as_ref().map(|h| h.is_ping_healthy()).unwrap_or(true) - }; - let status = if healthy { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - let body = json!({ "status": if healthy { "ok" } else { "engine_ping_stale" }, "runtime": "container-runner", "version": PKG_VERSION }); - (status, axum::Json(body)).into_response() - } - ("GET", "/metadata") => axum::Json(rt.metadata_json()).into_response(), - ("GET", "/metrics") => (StatusCode::OK, "# metrics not implemented\n").into_response(), - ("GET", "/start") | ("POST", "/start") => start(rt, req).await, - ("OPTIONS", _) => StatusCode::NO_CONTENT.into_response(), - _ => (StatusCode::NOT_FOUND, "Not Found (container-runner)\n").into_response(), - } -} - -async fn start(rt: Arc, req: Request) -> Response { - let (parts, body) = req.into_parts(); - - let headers = match parse_start_headers(&parts.headers) { - Ok(h) => h, - Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")).into_response(), - }; - - let body_bytes = match axum::body::to_bytes(body, rt.settings.max_start_payload_bytes).await { - Ok(b) => b.to_vec(), - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - format!("failed to read body: {e}\n"), - ) - .into_response(); - } - }; - - let handle = match rt.ensure_envoy(&headers).await { - Ok(h) => h, - Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")).into_response(), - }; - - let actor_start = match handle.decode_serverless_actor_start(&body_bytes) { - Ok(s) => s, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - format!("failed to decode start: {e}\n"), - ) - .into_response(); - } - }; - - // SSE stream: keep the request open for the actor's lifetime. - let (tx, rx) = mpsc::unbounded_channel::, std::io::Error>>(); - let _ = tx.send(Ok(SSE_PING_FRAME.to_vec())); - - let rt_task = rt.clone(); - tokio::spawn(async move { - if let Err(e) = handle.start_serverless_actor(&body_bytes).await { - tracing::error!(error = ?e, actorId = %actor_start.actor_id, "start_serverless_actor failed"); - let _ = tx.send(Err(std::io::Error::other(format!("{e}")))); - // A failed start leaves this instance poisoned; don't let it serve another actor. - rt_task.request_exit(&actor_start.actor_id, "start_serverless_actor failed"); - return; - } - loop { - tokio::select! { - _ = handle.wait_actor_registered_then_stopped(&actor_start.actor_id, actor_start.generation) => { - let _ = tx.send(Ok(SSE_STOPPING_FRAME.to_vec())); - break; - } - _ = tokio::time::sleep(SSE_PING_INTERVAL) => { - if tx.send(Ok(SSE_PING_FRAME.to_vec())).is_err() { - break; - } - } - } - } - // `/start` is ending. Dropping `tx` here ends the SSE body, which lets axum's - // graceful shutdown complete; then the process exits. - drop(tx); - rt_task.request_exit(&actor_start.actor_id, "actor stopped"); - }); - - let stream = UnboundedReceiverStream::new(rx).map(|chunk| chunk.map(Bytes::from)); - let mut resp = Response::new(Body::from_stream(stream)); - let h = resp.headers_mut(); - h.insert("content-type", "text/event-stream".parse().unwrap()); - h.insert("cache-control", "no-cache".parse().unwrap()); - h.insert("connection", "keep-alive".parse().unwrap()); - resp -} diff --git a/container-runner/tests/inline/boot_id.rs b/container-runner/tests/inline/boot_id.rs new file mode 100644 index 0000000000..fda41e9061 --- /dev/null +++ b/container-runner/tests/inline/boot_id.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn base64url_is_url_safe_and_unpadded() { + // Bytes chosen so std base64 would emit '+' and '/'. + assert_eq!(base64url_nopad(&[0xfb, 0xff, 0xbf]), "-_-_"); + assert_eq!(base64url_nopad(&[0x03]), "Aw"); + assert_eq!(base64url_nopad(b"hello"), "aGVsbG8"); + // 32 bytes -> 43 chars, no '=' padding. + assert_eq!(base64url_nopad(&[0u8; 32]).len(), 43); + assert!(!base64url_nopad(&[0u8; 32]).contains('=')); +} + +#[test] +fn boot_id_is_stable_within_a_process() { + let first = boot_id(); + let second = boot_id(); + assert_eq!(first, second); + assert!(!first.is_empty()); +} diff --git a/container-runner/tests/inline/input.rs b/container-runner/tests/inline/input.rs new file mode 100644 index 0000000000..30f117cd66 --- /dev/null +++ b/container-runner/tests/inline/input.rs @@ -0,0 +1,45 @@ +use super::*; + +fn decode(json: &str) -> anyhow::Result { + // Field handling (defaults, deny_unknown_fields) is format-agnostic in + // serde, so JSON exercises the same paths as the CBOR wire encoding. + Ok(serde_json::from_str(json)?) +} + +#[test] +fn empty_object_yields_defaults() { + let input = decode("{}").unwrap(); + assert!(input.command.is_none()); + assert!(input.args.is_empty()); + assert!(input.env.is_empty()); + assert!(input.port.is_none()); +} + +#[test] +fn full_input_decodes() { + let input = decode( + r#"{"command":["./GameServer","-batchmode"],"args":["-x"],"env":{"A":"1"},"port":7777}"#, + ) + .unwrap(); + assert_eq!( + input.command.as_deref(), + Some(&["./GameServer".to_string(), "-batchmode".to_string()][..]) + ); + assert_eq!(input.args, vec!["-x".to_string()]); + assert_eq!(input.env.get("A").map(String::as_str), Some("1")); + assert_eq!(input.port, Some(7777)); +} + +#[test] +fn unknown_fields_are_rejected() { + assert!(decode(r#"{"prot":123}"#).is_err()); +} + +#[test] +fn default_matches_empty() { + let default = ActorInput::default(); + assert!(default.command.is_none()); + assert!(default.args.is_empty()); + assert!(default.env.is_empty()); + assert!(default.port.is_none()); +} diff --git a/container-runner/tests/inline/inspector.rs b/container-runner/tests/inline/inspector.rs deleted file mode 100644 index f415aa71f9..0000000000 --- a/container-runner/tests/inline/inspector.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::*; - -#[test] -fn matches_inspector_ui_paths() { - assert!(is_inspector_ui_path("/inspector/ui/")); - assert!(is_inspector_ui_path("/inspector/ui/index.html")); - assert!(is_inspector_ui_path("/inspector/ui/assets/app.js")); - assert!(is_inspector_ui_path("/inspector/ui/?foo=bar")); -} - -#[test] -fn ignores_non_inspector_paths() { - assert!(!is_inspector_ui_path("/")); - assert!(!is_inspector_ui_path("/inspector")); - assert!(!is_inspector_ui_path("/inspector/")); - assert!(!is_inspector_ui_path("/game/state")); - assert!(!is_inspector_ui_path("/metadata")); - // Prefix must be at the start, not merely contained. - assert!(!is_inspector_ui_path("/x/inspector/ui/")); -} - -#[test] -fn response_is_html_hello_world_with_ready() { - let resp = inspector_response(); - assert_eq!(resp.status, 200); - assert_eq!( - resp.headers.get("content-type").map(String::as_str), - Some("text/html; charset=utf-8") - ); - let body = String::from_utf8(resp.body.expect("body")).unwrap(); - assert!(body.contains("

Hello World

")); - // The host validates ready with z.literal("ready") AND z.literal(1) for `v`. - // Dropping `v` makes safeParse fail silently -> iframe never unhides. - assert!(body.contains(r#"post({ type: "ready", v: 1 })"#)); - // The init payload field is `authToken`, not `auth`. - assert!(body.contains("d.authToken")); - assert!( - !body.contains("d.auth "), - "must not read the nonexistent `auth` field" - ); -} - -/// We speak the tab protocol but advertise nothing, so the host renders this page -/// with no tab chips. Adding one later means pushing an `{id,label,icon}` entry. -#[test] -fn supports_tabs_but_advertises_none() { - let body = String::from_utf8(inspector_response().body.expect("body")).unwrap(); - assert!(body.contains(r#"post({ type: "tabs-available", v: 1, tabs: [] })"#)); - // Nothing should declare a tab entry. - assert!(!body.contains(r#"icon: ""#), "no tab should be advertised"); - // A custom tab would make the host navigate to /inspector/custom-tabs/{id}/, - // which we do not serve. (The identifier appears in a comment, so assert on the - // enabled form rather than the bare word.) - assert!(!body.contains("isCustom: true")); -} - -#[test] -fn base64url_is_url_safe_and_unpadded() { - // Bytes chosen so std base64 would emit '+' and '/'. - assert_eq!(base64url_nopad(&[0xfb, 0xff, 0xbf]), "-_-_"); - assert_eq!(base64url_nopad(&[0x03]), "Aw"); - assert_eq!(base64url_nopad(b"hello"), "aGVsbG8"); - // 32 random-length bytes -> 43 chars, no '=' padding. - assert_eq!(base64url_nopad(&[0u8; 32]).len(), 43); - assert!(!base64url_nopad(&[0u8; 32]).contains('=')); -} - -#[test] -fn matches_metadata_path() { - assert!(is_metadata_path("/metadata")); - assert!(is_metadata_path("/metadata/")); - assert!(is_metadata_path("/metadata?x=1")); -} - -#[test] -fn ignores_non_metadata_paths() { - assert!(!is_metadata_path("/api/rivet/metadata")); - assert!(!is_metadata_path("/metadata/extra")); - assert!(!is_metadata_path("/")); - assert!(!is_metadata_path("/inspector/ui/")); -} - -#[test] -fn metadata_reports_version_at_or_above_2_3_0() { - let r = metadata_response(); - assert_eq!(r.status, 200); - let body = String::from_utf8(r.body.unwrap()).unwrap(); - assert!(body.contains("\"version\":\"2.3.0\""), "body: {body}"); - assert!(body.contains("\"runtime\":\"rivetkit\"")); - // The gateway injects CORS itself; we must not set it. - assert!( - !r.headers - .keys() - .any(|k| k.to_lowercase().contains("access-control")) - ); -} diff --git a/website/src/content/docs/deploy/container-runner.mdx b/website/src/content/docs/deploy/container-runner.mdx index 136a712bf5..36df7561bb 100644 --- a/website/src/content/docs/deploy/container-runner.mdx +++ b/website/src/content/docs/deploy/container-runner.mdx @@ -32,7 +32,7 @@ Artifacts are published for `x86_64-unknown-linux-musl` and `aarch64-unknown-lin 1. The engine cold-starts your container and calls `POST /api/rivet/start` on the port it injects as `RIVET_PORT`. 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. -3. Gateway traffic for the actor arrives over Rivet's tunnel and is proxied to `127.0.0.1:`. WebSocket clients connect through the gateway URL with the `rivet` WebSocket subprotocol. +3. Gateway traffic for the actor arrives over Rivet's tunnel and is proxied to `127.0.0.1:`. 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. 4. Child stdout and stderr are re-emitted with an `[actorId=... key=...]` prefix so actor logs are attributed in the dashboard. 5. When the actor stops, the runner sends the child `SIGTERM`, escalates to `SIGKILL` after a grace period, and exits. @@ -52,7 +52,7 @@ All flags can also be set through environment variables: ### Per-Actor Input -The actor's `input` payload can override the launch spec per actor. All fields are optional and fall back to the entrypoint command: +The actor's `input` payload can override the launch spec per actor. All fields are optional and fall back to the entrypoint command. RivetKit clients pass this object directly; when creating actors through the raw engine API, encode it as CBOR before base64-encoding the `input` field: ```json {