Skip to content

Commit 8760396

Browse files
authored
fix(compute): revalidate late exit events after restart (#4235)
Signed-off-by: Simon Scatton <sscatton@nvidia.com>
1 parent 0bca9fb commit 8760396

2 files changed

Lines changed: 128 additions & 35 deletions

File tree

‎crates/openshell-server/src/compute/mod.rs‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4434,18 +4434,23 @@ impl ComputeRuntime {
44344434
SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown)
44354435
});
44364436

4437-
if existing_phase != SandboxPhase::Starting {
4437+
let ready_container_exit = existing_phase == SandboxPhase::Ready
4438+
&& (driver_snapshot_confirms_stopped(&incoming)
4439+
|| driver_snapshot_reports_runtime_restart(&incoming));
4440+
if existing_phase != SandboxPhase::Starting && !ready_container_exit {
44384441
return self.apply_sandbox_update_locked(incoming, existing).await;
44394442
}
44404443

44414444
// Any snapshot can already be queued when StartSandbox moves the
44424445
// durable phase to Starting. In particular, an old-generation Ready
44434446
// event followed by its terminal event can otherwise promote and then
44444447
// stop the new generation before the replacement supervisor connects.
4448+
// The replacement may already be Ready when the old exit arrives, so
4449+
// terminal container snapshots need the same check after readiness.
44454450
// Release the global watch lock, wait for that lifecycle operation,
44464451
// and then reread both the driver and store before applying an
44474452
// authoritative observation. Taking the per-sandbox gate only for
4448-
// this ambiguous phase avoids delaying unrelated watch events behind
4453+
// these ambiguous snapshots avoids delaying unrelated watch events behind
44494454
// slow lifecycle operations.
44504455
let existing_name = existing_sandbox.as_ref().map_or_else(
44514456
|| incoming.name.clone(),
@@ -4475,7 +4480,7 @@ impl ComputeRuntime {
44754480
{
44764481
warn!(
44774482
sandbox_id = %incoming.id,
4478-
"Could not validate driver snapshot during sandbox start; retaining current sandbox state"
4483+
"Could not validate driver snapshot against current runtime; retaining current sandbox state"
44794484
);
44804485
return Ok(());
44814486
}
@@ -11604,6 +11609,65 @@ mod tests {
1160411609
}
1160511610
}
1160611611

11612+
#[tokio::test]
11613+
async fn container_exit_after_replacement_ready_is_revalidated() {
11614+
for (reason, live_exited) in [
11615+
("ContainerExited", false),
11616+
("ContainerExited", true),
11617+
("ContainerStopped", false),
11618+
("ContainerStopped", true),
11619+
("ContainerRuntimeRestart", false),
11620+
("ContainerRuntimeRestart", true),
11621+
] {
11622+
let driver = ControlledDriver::new();
11623+
let mut sandbox =
11624+
sandbox_record("sb-restarted", "sandbox-restarted", SandboxPhase::Starting);
11625+
sandbox.status.as_mut().unwrap().restart_count = 1;
11626+
sandbox.status.as_mut().unwrap().exit_code = Some(17);
11627+
sandbox.status.as_mut().unwrap().main_process_instance_id = "previous".to_string();
11628+
let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
11629+
exited.status = Some(make_driver_status(make_driver_condition(
11630+
reason,
11631+
"container exited",
11632+
)));
11633+
let live = if live_exited {
11634+
exited.clone()
11635+
} else {
11636+
ready_driver_sandbox(sandbox.object_id(), sandbox.object_name())
11637+
};
11638+
driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(live)));
11639+
let runtime = test_runtime(driver).await;
11640+
runtime.store.put_message(&sandbox).await.unwrap();
11641+
register_test_supervisor_session(&runtime, sandbox.object_id());
11642+
runtime
11643+
.supervisor_session_connected(sandbox.object_id(), "replacement")
11644+
.await
11645+
.unwrap();
11646+
11647+
runtime.apply_sandbox_update(exited).await.unwrap();
11648+
11649+
let stored = runtime
11650+
.store
11651+
.get_message::<Sandbox>(sandbox.object_id())
11652+
.await
11653+
.unwrap()
11654+
.unwrap();
11655+
assert_eq!(
11656+
stored.phase(),
11657+
if live_exited {
11658+
SandboxPhase::Error
11659+
} else {
11660+
SandboxPhase::Ready
11661+
} as i32,
11662+
"only a live {reason} may fail the replacement runtime"
11663+
);
11664+
let status = stored.status.unwrap();
11665+
assert_eq!(status.main_process_instance_id, "replacement");
11666+
assert_eq!(status.restart_count, 1);
11667+
assert_eq!(status.exit_code, None);
11668+
}
11669+
}
11670+
1160711671
#[tokio::test]
1160811672
async fn stale_ready_snapshot_queued_before_start_is_revalidated() {
1160911673
let driver = ControlledDriver::new();
@@ -13375,14 +13439,18 @@ mod tests {
1337513439

1337613440
#[tokio::test]
1337713441
async fn unexpected_term_runtime_restart_transitions_to_error() {
13378-
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
13442+
let driver = ControlledDriver::new();
13443+
let runtime = test_runtime(driver.clone()).await;
1337913444
let sandbox = sandbox_record("sb-term-exit", "sandbox-term-exit", SandboxPhase::Ready);
1338013445
runtime.store.put_message(&sandbox).await.unwrap();
1338113446
let mut runtime_restart = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
1338213447
runtime_restart.status = Some(make_driver_status(make_driver_condition(
1338313448
"ContainerRuntimeRestart",
1338413449
"container exited with status 143",
1338513450
)));
13451+
driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(
13452+
runtime_restart.clone(),
13453+
)));
1338613454

1338713455
runtime.apply_sandbox_update(runtime_restart).await.unwrap();
1338813456

‎e2e/rust/tests/sandbox_lifecycle.rs‎

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ use std::process::Stdio;
99
use std::time::Duration;
1010

1111
use openshell_e2e::harness::binary::{openshell_cmd, openshell_tty_cmd};
12-
use openshell_e2e::harness::cli::{run_cli, wait_for_sandbox_phase};
12+
use openshell_e2e::harness::cli::{
13+
run_cli, wait_for_sandbox_exec_contains, wait_for_sandbox_phase,
14+
};
1315
use openshell_e2e::harness::output::{extract_field, strip_ansi};
14-
use openshell_e2e::harness::sandbox::{SandboxGuard, unique_sandbox_name};
16+
use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard, unique_sandbox_name};
1517
use serial_test::serial;
1618
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1719
use tokio::time::{Instant, sleep};
@@ -1300,45 +1302,81 @@ async fn canonical_main_exit_255_is_not_retried_as_transport_failure() {
13001302
#[tokio::test]
13011303
#[serial(sandbox_lifecycle)]
13021304
async fn on_failure_policy_replaces_runtime_and_preserves_workspace() {
1303-
const FIRST_MARKER: &str = "initial-main-ready";
13041305
const SCRIPT: &str = r#"
13051306
marker=/sandbox/.openshell-restart-e2e
13061307
if [ -e "$marker" ]; then
13071308
printf 'replacement-%s\n' "$(cat /proc/sys/kernel/random/uuid)" > /sandbox/replacement-run
1308-
printf 'replacement-main-ready\n'
13091309
sleep 300
13101310
else
13111311
touch "$marker"
13121312
printf 'initial-%s\n' "$(cat /proc/sys/kernel/random/uuid)" > /sandbox/initial-run
1313-
printf 'initial-main-ready\n'
1314-
sleep 2
1313+
while [ ! -e /sandbox/.openshell-restart-release ]; do sleep 0.05; done
13151314
exit 17
13161315
fi
13171316
"#;
13181317

1319-
let mut sandbox = SandboxGuard::create_keep_with_args(
1320-
&["--restart-policy", "on-failure"],
1321-
&["sh", "-lc", SCRIPT],
1322-
FIRST_MARKER,
1318+
let mut sandbox = SandboxGuard::manage_existing(unique_sandbox_name());
1319+
let (output, exit_code) = run_cli(&[
1320+
"sandbox",
1321+
"create",
1322+
"--name",
1323+
&sandbox.name,
1324+
"--from",
1325+
E2E_WORKLOAD_IMAGE,
1326+
"--detach",
1327+
"--restart-policy",
1328+
"on-failure",
1329+
"--",
1330+
"sh",
1331+
"-lc",
1332+
SCRIPT,
1333+
])
1334+
.await;
1335+
assert_eq!(
1336+
exit_code, 0,
1337+
"create sandbox with OnFailure policy: {output}"
1338+
);
1339+
wait_for_sandbox_exec_contains(
1340+
&sandbox.name,
1341+
&["cat", "/sandbox/initial-run"],
1342+
"initial-",
1343+
SANDBOX_RESTART_TIMEOUT,
13231344
)
13241345
.await
1325-
.expect("create sandbox with OnFailure restart policy");
1346+
.expect("initial main process should write its workspace marker");
1347+
1348+
let initial_details = sandbox_details(&sandbox.name).await;
1349+
let initial_instance = extract_field(&initial_details, "Main process instance")
1350+
.expect("initial main process instance");
1351+
sandbox
1352+
.exec(&["touch", "/sandbox/.openshell-restart-release"])
1353+
.await
1354+
.expect("release initial main process");
13261355

13271356
let deadline = Instant::now() + SANDBOX_RESTART_TIMEOUT;
1328-
let mut observed_replacement_starting = false;
1357+
let mut last_runs = Err("replacement is not ready".to_string());
13291358
let final_details = loop {
13301359
let details = sandbox_details(&sandbox.name).await;
1331-
observed_replacement_starting |= details.contains("Phase: Starting");
1332-
if observed_replacement_starting
1333-
&& details.contains("Phase: Ready")
1334-
&& details.contains("Restart count: 1")
1360+
let replacement_instance = extract_field(&details, "Main process instance")
1361+
.is_some_and(|instance| instance != "-" && instance != initial_instance);
1362+
if details.contains("Phase: Ready")
1363+
&& extract_field(&details, "Restart count").as_deref() == Some("1")
1364+
&& replacement_instance
13351365
{
1336-
break details;
1366+
last_runs = sandbox
1367+
.exec(&["cat", "/sandbox/initial-run", "/sandbox/replacement-run"])
1368+
.await;
1369+
if let Ok(runs) = &last_runs
1370+
&& runs.contains("initial-")
1371+
&& runs.contains("replacement-")
1372+
{
1373+
break details;
1374+
}
13371375
}
13381376
assert!(
13391377
Instant::now() < deadline,
13401378
"sandbox did not complete its policy-driven restart within \
1341-
{SANDBOX_RESTART_TIMEOUT:?}; last details:\n{details}"
1379+
{SANDBOX_RESTART_TIMEOUT:?}; last details:\n{details}\nworkspace: {last_runs:?}"
13421380
);
13431381
sleep(Duration::from_millis(250)).await;
13441382
};
@@ -1347,19 +1385,6 @@ fi
13471385
final_details.contains("Restart policy: on-failure"),
13481386
"restart policy should remain visible after replacement:\n{final_details}"
13491387
);
1350-
let runs = sandbox
1351-
.exec(&["cat", "/sandbox/initial-run", "/sandbox/replacement-run"])
1352-
.await
1353-
.expect("replacement sandbox should retain the first run's workspace");
1354-
assert!(
1355-
runs.contains("initial-"),
1356-
"missing initial run marker:\n{runs}"
1357-
);
1358-
assert!(
1359-
runs.contains("replacement-"),
1360-
"missing replacement run marker:\n{runs}"
1361-
);
1362-
13631388
sandbox.cleanup().await;
13641389
}
13651390

0 commit comments

Comments
 (0)