Skip to content

Commit 3f1016b

Browse files
committed
feat(container-runner): report actors as crashed on unexpected platform SIGTERM
1 parent 4346f55 commit 3f1016b

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

container-runner/src/actor.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! `on_destroy` stops the child while the instance stays warm for the next
88
//! placement.
99
10-
use std::sync::Arc;
10+
use std::sync::{Arc, LazyLock};
1111

1212
use anyhow::{Context, Result};
1313
use async_trait::async_trait;
@@ -21,6 +21,12 @@ use crate::{
2121
runner_config,
2222
};
2323

24+
/// Live actor contexts on this instance, keyed by actor id. Lets the process
25+
/// shutdown path report actors as crashed when the platform reclaims the
26+
/// container out from under them.
27+
static ACTOR_CTXS: LazyLock<scc::HashMap<String, Ctx<GameServer>>> =
28+
LazyLock::new(scc::HashMap::new);
29+
2430
pub struct GameServer {
2531
child: TokioMutex<Option<Arc<ChildProcess>>>,
2632
}
@@ -35,6 +41,7 @@ impl GameServer {
3541
// deliberate, then stop. `stop` is idempotent if the process shutdown
3642
// sweep already stopped this child.
3743
children().remove_async(actor_id).await;
44+
ACTOR_CTXS.remove_async(actor_id).await;
3845
let child = self.child.lock().await.take();
3946
if let Some(child) = child {
4047
child.stop(effective_stop_grace()).await;
@@ -87,6 +94,7 @@ impl Actor for GameServer {
8794
"{} runner: actor already running, ignoring duplicate start",
8895
log_prefix(&actor_id, existing.key.as_deref())
8996
);
97+
register_ctx(&actor_id, &ctx).await;
9098
*self.child.lock().await = Some(existing);
9199
return Ok(());
92100
}
@@ -152,6 +160,10 @@ impl Actor for GameServer {
152160
release_child_port(child_port).await;
153161
anyhow::bail!("a child for actor {actor_id} is already registered");
154162
}
163+
// Register only now that startup has succeeded. Registering earlier would
164+
// leak an entry for any generation whose start failed, since a failed
165+
// start never runs on_destroy/on_sleep to remove it.
166+
register_ctx(&actor_id, &ctx).await;
155167
*self.child.lock().await = Some(child);
156168
Ok(())
157169
}
@@ -236,6 +248,38 @@ impl Actor for GameServer {
236248
}
237249
}
238250

251+
/// Register an actor context for crash-on-shutdown reporting. Overwrites any
252+
/// stale entry left by a prior generation with the same id.
253+
async fn register_ctx(actor_id: &str, ctx: &Ctx<GameServer>) {
254+
ACTOR_CTXS.remove_async(actor_id).await;
255+
let _ = ACTOR_CTXS
256+
.insert_async(actor_id.to_string(), ctx.clone())
257+
.await;
258+
}
259+
260+
/// Report every live actor on this instance as crashed. Called when the
261+
/// platform reclaims the container (an unexpected SIGTERM) so the reclaim
262+
/// surfaces as a crash on the engine instead of a silent reallocation. Runs
263+
/// while the envoy is still connected so the crash reaches the engine.
264+
pub async fn crash_all_actors(message: &str) {
265+
let mut ctxs = Vec::new();
266+
ACTOR_CTXS
267+
.retain_async(|_, ctx| {
268+
ctxs.push(ctx.clone());
269+
false
270+
})
271+
.await;
272+
for ctx in ctxs {
273+
if let Err(err) = ctx.stop_with_error(message) {
274+
tracing::debug!(
275+
actor_id = %ctx.actor_id(),
276+
error = ?err,
277+
"crash-on-shutdown stop_with_error failed"
278+
);
279+
}
280+
}
281+
}
282+
239283
fn actor_key_string(ctx: &Ctx<GameServer>) -> Option<String> {
240284
let key = ctx.key();
241285
if key.is_empty() {

container-runner/src/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,17 @@ async fn async_main() -> Result<()> {
368368
// and the runtime drains unbounded so the /start SSE flushes cleanly.
369369
EXIT.cancelled().await;
370370
if SIGNAL_SHUTDOWN.load(Ordering::Acquire) {
371+
// A platform SIGTERM reclaims this instance. Report every actor as crashed
372+
// before draining so an unexpected SIGTERM (OOM or the ~60 minute request
373+
// cap) surfaces as a crash on the engine instead of a silent reallocation.
374+
// This runs while the envoy is still connected so the crash reaches the
375+
// engine. A local SIGINT (Ctrl-C) drains gracefully without a crash.
376+
if PLATFORM_RECLAIM.load(Ordering::Acquire) {
377+
crate::actor::crash_all_actors(
378+
"runner received unexpected platform SIGTERM, likely OOM or running longer than 60 minutes",
379+
)
380+
.await;
381+
}
371382
if tokio::time::timeout(signal_drain_timeout(), runtime.shutdown())
372383
.await
373384
.is_err()

0 commit comments

Comments
 (0)