Skip to content

Commit 2935e97

Browse files
authored
fix(gateway): delete finalized ephemeral sandboxes while connected (#3984)
Start driver cleanup after terminal finalization and retain disconnect fallback. Add detached success and failure e2e coverage across supervisor-based drivers. Closes #3938 Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 5a91572 commit 2935e97

7 files changed

Lines changed: 322 additions & 8 deletions

File tree

‎.agents/skills/launch-openshell-gator/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ sandbox_name="gator-pr-${pr_number}-supervised"
158158
"Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}."
159159
```
160160

161-
The launcher queries the gateway's selected compute driver, builds the gator image in the matching Docker or Podman image store, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, and starts the agent supervisor as the sandbox's canonical main process. The detached main process survives loss of the host CLI connection and reconnects to a restarted gateway. Unless `--keep` is set, the sandbox is marked ephemeral so the gateway deletes it after the supervisor exits. `CONTAINER_ENGINE`, when set, must match the gateway driver.
161+
The launcher queries the gateway's selected compute driver, builds the gator image in the matching Docker or Podman image store, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, and starts the agent supervisor as the sandbox's canonical main process. The detached main process survives loss of the host CLI connection and reconnects to a restarted gateway. Unless `--keep` is set, the sandbox is marked ephemeral so the gateway deletes it after the canonical main process exits and its terminal result is finalized. `CONTAINER_ENGINE`, when set, must match the gateway driver.
162162

163163
The launcher streams image-build and provisioning output until the detached workload is ready, then exits. Use `openshell logs <sandbox-name>` or the TUI for runtime output.
164164

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

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,6 +2135,7 @@ impl ComputeRuntime {
21352135
}
21362136
}
21372137

2138+
#[cfg(test)]
21382139
pub(crate) async fn delete_sandbox(
21392140
&self,
21402141
workspace: &str,
@@ -4725,6 +4726,39 @@ impl ComputeRuntime {
47254726
Ok(())
47264727
}
47274728

4729+
/// Start ephemeral cleanup only after the finalize RPC has recorded the
4730+
/// terminal result and marked its supervisor session finalized.
4731+
pub async fn cleanup_finalized_ephemeral_sandbox(
4732+
&self,
4733+
sandbox_id: &str,
4734+
instance_id: &str,
4735+
) -> Result<(), String> {
4736+
let _guard = self.sync_lock.lock().await;
4737+
let Some(sandbox) = self
4738+
.store
4739+
.get_message::<Sandbox>(sandbox_id)
4740+
.await
4741+
.map_err(|error| error.to_string())?
4742+
else {
4743+
return Ok(());
4744+
};
4745+
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
4746+
if phase != SandboxPhase::Completed && !is_failed_main_process_result(&sandbox) {
4747+
return Ok(());
4748+
}
4749+
let Some(status) = sandbox.status.as_ref() else {
4750+
return Ok(());
4751+
};
4752+
if status.exit_code.is_none()
4753+
|| (!status.main_process_instance_id.is_empty()
4754+
&& status.main_process_instance_id != instance_id)
4755+
{
4756+
return Ok(());
4757+
}
4758+
self.schedule_ephemeral_sandbox_delete(&sandbox);
4759+
Ok(())
4760+
}
4761+
47284762
fn schedule_ephemeral_sandbox_delete(&self, sandbox: &Sandbox) {
47294763
if provisioning_deadline::timed_out(sandbox) {
47304764
return;
@@ -4740,10 +4774,10 @@ impl ComputeRuntime {
47404774
}
47414775

47424776
let runtime = self.clone();
4743-
let workspace = sandbox.object_workspace().to_string();
4777+
let sandbox_id = sandbox.object_id().to_string();
47444778
let name = sandbox.object_name().to_string();
47454779
tokio::spawn(async move {
4746-
if let Err(error) = runtime.delete_sandbox(&workspace, &name).await {
4780+
if let Err(error) = runtime.delete_sandbox_by_id(&sandbox_id, &name).await {
47474781
tracing::warn!(
47484782
sandbox_name = %name,
47494783
error = %error,
@@ -9300,7 +9334,83 @@ mod tests {
93009334
.unwrap();
93019335
assert_eq!(driver.delete_calls(), 0);
93029336
runtime
9303-
.supervisor_session_disconnected("sb-1", true)
9337+
.cleanup_finalized_ephemeral_sandbox("sb-1", "instance-1")
9338+
.await
9339+
.unwrap();
9340+
tokio::time::timeout(Duration::from_secs(1), async {
9341+
while driver.delete_calls() == 0 {
9342+
tokio::task::yield_now().await;
9343+
}
9344+
})
9345+
.await
9346+
.expect("terminal finalization should delete before the supervisor disconnects");
9347+
}
9348+
9349+
#[tokio::test]
9350+
async fn finalized_ephemeral_cleanup_skips_retained_and_restarting_sandboxes() {
9351+
for (retention, restart_policy, exit_code) in [
9352+
(None, SandboxRestartPolicy::Never, 0),
9353+
(Some("ephemeral"), SandboxRestartPolicy::OnFailure, 9),
9354+
] {
9355+
let driver = ControlledDriver::new();
9356+
let runtime = test_runtime(driver.clone()).await;
9357+
let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning);
9358+
if let Some(retention) = retention {
9359+
sandbox.metadata.as_mut().unwrap().annotations.insert(
9360+
"openshell.nvidia.com/retention".to_string(),
9361+
retention.to_string(),
9362+
);
9363+
}
9364+
sandbox.spec = Some(SandboxSpec {
9365+
restart_policy: restart_policy as i32,
9366+
..Default::default()
9367+
});
9368+
runtime.store.put_message(&sandbox).await.unwrap();
9369+
runtime
9370+
.supervisor_session_connected("sb-1", "instance-1")
9371+
.await
9372+
.unwrap();
9373+
runtime
9374+
.report_main_process_exit("sb-1", "instance-1", exit_code)
9375+
.await
9376+
.unwrap();
9377+
runtime
9378+
.finalize_main_process_exit("sb-1", "instance-1")
9379+
.await
9380+
.unwrap();
9381+
runtime
9382+
.cleanup_finalized_ephemeral_sandbox("sb-1", "instance-1")
9383+
.await
9384+
.unwrap();
9385+
tokio::task::yield_now().await;
9386+
assert_eq!(driver.delete_calls(), 0);
9387+
}
9388+
}
9389+
9390+
#[tokio::test]
9391+
async fn finalized_failed_ephemeral_sandbox_deletes_while_connected() {
9392+
let driver = ControlledDriver::new();
9393+
let runtime = test_runtime(driver.clone()).await;
9394+
let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning);
9395+
sandbox.metadata.as_mut().unwrap().annotations.insert(
9396+
"openshell.nvidia.com/retention".to_string(),
9397+
"ephemeral".to_string(),
9398+
);
9399+
runtime.store.put_message(&sandbox).await.unwrap();
9400+
runtime
9401+
.supervisor_session_connected("sb-1", "instance-1")
9402+
.await
9403+
.unwrap();
9404+
runtime
9405+
.report_main_process_exit("sb-1", "instance-1", 9)
9406+
.await
9407+
.unwrap();
9408+
runtime
9409+
.finalize_main_process_exit("sb-1", "instance-1")
9410+
.await
9411+
.unwrap();
9412+
runtime
9413+
.cleanup_finalized_ephemeral_sandbox("sb-1", "instance-1")
93049414
.await
93059415
.unwrap();
93069416
tokio::time::timeout(Duration::from_secs(1), async {
@@ -9309,7 +9419,7 @@ mod tests {
93099419
}
93109420
})
93119421
.await
9312-
.expect("terminal finalization should release ephemeral cleanup");
9422+
.expect("failed canonical main should delete its ephemeral sandbox");
93139423
}
93149424

93159425
#[tokio::test]

‎crates/openshell-server/src/supervisor_session.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,10 +2072,18 @@ pub async fn handle_finalize_main_process_exit(
20722072
.finalize_main_process_exit(&report.sandbox_id, &report.instance_id)
20732073
.await
20742074
.map_err(Status::failed_precondition)?;
2075-
if !state
2075+
let session_finalized = state
20762076
.supervisor_sessions
2077-
.finalize_main_process_exit(&report.sandbox_id)
2078-
{
2077+
.finalize_main_process_exit(&report.sandbox_id);
2078+
// The session can close between durable result validation and this mark.
2079+
// Schedule cleanup in either case so a disconnect with an unfinalized
2080+
// in-memory session cannot strand the ephemeral sandbox.
2081+
state
2082+
.compute
2083+
.cleanup_finalized_ephemeral_sandbox(&report.sandbox_id, &report.instance_id)
2084+
.await
2085+
.map_err(Status::internal)?;
2086+
if !session_finalized {
20792087
return Err(Status::failed_precondition(
20802088
"supervisor session is not connected",
20812089
));

‎e2e/rust/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ name = "local_driver_token_restart"
9898
path = "tests/local_driver_token_restart.rs"
9999
required-features = ["e2e"]
100100

101+
[[test]]
102+
name = "ephemeral_cleanup"
103+
path = "tests/ephemeral_cleanup.rs"
104+
required-features = ["e2e"]
105+
101106
[[test]]
102107
name = "podman_gateway_start"
103108
path = "tests/podman_gateway_start.rs"

‎e2e/rust/e2e-podman.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ source "${ROOT}/e2e/support/conformance.sh"
2222
# stabilized and can be added here.
2323
PODMAN_CI_TESTS=(
2424
bypass_detection
25+
ephemeral_cleanup
2526
core_dump_hardening
2627
credential_gating
2728
default_image

‎e2e/rust/e2e-vm.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ run_e2e_test() {
411411
if [ -n "${E2E_TEST_OVERRIDE}" ]; then
412412
run_e2e_test "${E2E_TEST_OVERRIDE}"
413413
else
414+
run_e2e_test ephemeral_cleanup
414415
run_e2e_test host_gateway_alias
415416
run_e2e_test vm_overlay
416417
run_e2e_test vm_gateway_start

0 commit comments

Comments
 (0)