From aee8b0b5f6a40d4fcb159ab15e021ec1067508f9 Mon Sep 17 00:00:00 2001 From: ABCxFF <79597906+abcxff@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:29:16 -0400 Subject: [PATCH] feat(container-runner): sleep on startup idle timeout --- container-runner/src/actor.rs | 43 +++++++++++++++++++++++++++++++++-- container-runner/src/main.rs | 16 +++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/container-runner/src/actor.rs b/container-runner/src/actor.rs index 86b2befbc3..47594f52fb 100644 --- a/container-runner/src/actor.rs +++ b/container-runner/src/actor.rs @@ -4,6 +4,7 @@ //! watchdogs unexpected child exits, `on_fetch`/`on_websocket` proxy tunneled //! traffic to the child, and `on_sleep`/`on_destroy` stop it. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; @@ -14,7 +15,7 @@ use tokio::sync::Mutex as TokioMutex; use crate::child::{ChildProcess, SpawnSpec, log_prefix}; use crate::input::ActorInput; use crate::{ - children, drain_grace, effective_stop_grace, exit_token, release_child_port, + children, drain_grace, effective_stop_grace, exit_token, idle_timeout, release_child_port, request_exit, reserve_child_port, runner_config, }; @@ -25,6 +26,9 @@ static ACTOR_CTXS: LazyLock>> = pub struct GameServer { child: TokioMutex>>, + /// Set on the first request, disarming the one-shot startup idle timer. Only + /// meaningful when [`idle_timeout`] is enabled. + got_request: AtomicBool, } impl GameServer { @@ -77,6 +81,31 @@ impl GameServer { } self.stop_child(actor_id, reason).await; } + + /// Arm the one-shot startup idle timer when [`idle_timeout`] is set. After the + /// window, if no request has arrived, ask the actor to sleep (`stop_child` then + /// exits the container). Cancelled early if the actor starts shutting down. + fn arm_idle_timeout(self: &Arc, ctx: &Ctx, actor_id: String) { + let Some(timeout) = idle_timeout() else { + return; + }; + let this = self.clone(); + let ctx = ctx.clone(); + tokio::spawn(async move { + let abort = ctx.abort_signal(); + tokio::select! { + _ = tokio::time::sleep(timeout) => {} + _ = abort.cancelled() => return, + } + if this.got_request.load(Ordering::Relaxed) { + return; + } + tracing::info!(actor_id = %actor_id, ?timeout, "no request within idle timeout, sleeping"); + if let Err(err) = ctx.sleep() { + tracing::debug!(error = ?err, actor_id = %actor_id, "idle sleep request failed"); + } + }); + } } #[async_trait] @@ -99,6 +128,7 @@ impl Actor for GameServer { async fn create(_ctx: &Ctx) -> Result { Ok(Self { child: TokioMutex::new(None), + got_request: AtomicBool::new(false), }) } @@ -206,6 +236,7 @@ impl Actor for GameServer { // hook to remove the entry, so registering earlier would leak it. register_ctx(&actor_id, &ctx).await; *self.child.lock().await = Some(child); + self.arm_idle_timeout(&ctx, actor_id); Ok(()) } @@ -241,6 +272,7 @@ impl Actor for GameServer { } async fn on_fetch(self: Arc, ctx: Ctx, req: Request) -> Result { + self.got_request.store(true, Ordering::Relaxed); let child_port = self .child .lock() @@ -257,6 +289,7 @@ impl Actor for GameServer { ws: WebSocket, req: Request, ) -> Result<()> { + self.got_request.store(true, Ordering::Relaxed); let child_port = self .child .lock() @@ -274,8 +307,14 @@ impl Actor for GameServer { /// Engine-initiated sleep. `no_sleep` blocks only idle sleep; the engine can /// still sleep an actor (dashboard, crash policy, eviction), so we stop the child. + /// In idle-timeout mode the sleep is (treated as) an idle sleep, so it skips the + /// drain and stops promptly; otherwise it drains for in-flight work first. async fn on_sleep(self: Arc, ctx: Ctx) -> Result<()> { - self.drain_then_stop_child(ctx.actor_id(), "actor sleeping").await; + if idle_timeout().is_some() { + self.stop_child(ctx.actor_id(), "actor sleeping (idle)").await; + } else { + self.drain_then_stop_child(ctx.actor_id(), "actor sleeping").await; + } Ok(()) } diff --git a/container-runner/src/main.rs b/container-runner/src/main.rs index cf0ebb854a..2e854eae5a 100644 --- a/container-runner/src/main.rs +++ b/container-runner/src/main.rs @@ -117,6 +117,22 @@ pub fn drain_grace() -> Duration { *DRAIN_GRACE } +/// One-shot startup idle timeout. If the actor receives no request within this +/// window of starting, it sleeps (and the container exits). `None` when +/// RIVET_IDLE_TIMEOUT_SECS is unset or 0 (disabled); the first request disarms it. +static IDLE_TIMEOUT: LazyLock> = LazyLock::new(|| { + let secs = std::env::var("RIVET_IDLE_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + (secs > 0).then(|| Duration::from_secs(secs)) +}); + +/// The one-shot startup idle-sleep window, or `None` when disabled. See [`IDLE_TIMEOUT`]. +pub fn idle_timeout() -> Option { + *IDLE_TIMEOUT +} + /// Token that fires on a platform shutdown signal. Cuts a drain wait short so the /// platform's SIGTERM→SIGKILL budget is honored. pub fn exit_token() -> &'static CancellationToken {