Skip to content

Commit 9cc6de9

Browse files
committed
fix(sandbox): fence terminal delivery and restarts
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 2e1fe05 commit 9cc6de9

18 files changed

Lines changed: 458 additions & 97 deletions

File tree

.agents/skills/openshell-cli/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,9 @@ Both commands default to the last-used sandbox. Stop stops background
358358
forwards and waits for `Stopped`; start waits for `Ready`. Connect, exec,
359359
file transfer, forwarding, and exposed services are unavailable while
360360
stopped or completed. Starting a retained `Completed` or
361-
`Error/MainProcessFailed` sandbox launches a fresh canonical-main instance.
362-
Delete remains the operation that removes retained state.
361+
`Error/MainProcessFailed` sandbox launches a fresh canonical-main instance and
362+
invalidates SSH sessions from the previous runtime generation. Delete remains
363+
the operation that removes retained state.
363364

364365
---
365366

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ without one, the default is `/bin/bash -l` with a PTY. Explicit commands remain
211211
foreground in non-interactive automation: stdout and stderr stream to the
212212
caller and the CLI returns the command's exact status. Exit 0 leaves
213213
`Completed`; nonzero leaves `Error/MainProcessFailed`.
214+
Starting either retained terminal result invalidates SSH sessions from the
215+
previous runtime generation.
214216

215217
| Flag | Description |
216218
|------|-------------|

architecture/compute-runtimes.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ A canonical main process that exits successfully follows `Ready -> Completed`.
141141
A nonzero or signal-normalized result follows `Ready -> Error` with a
142142
`MainProcessFailed` condition. Both retained results may be started explicitly,
143143
which creates a fresh main-process instance. Drivers must not automatically
144-
restart a completed or failed canonical process.
144+
restart a completed or failed canonical process. Before an explicit restart,
145+
the gateway disconnects the prior supervisor session and deletes its SSH
146+
sessions so credentials cannot cross runtime generations.
145147

146148
`StopSandbox` and `StartSandbox` are idempotent driver operations. Stop
147149
retains the driver resource and its persistent workspace boundary while making

architecture/gateway.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ The live supervisor session is the readiness authority for its main-process
2929
instance. The supervisor reports its normalized result through the
3030
sandbox-authenticated `ReportMainProcessExit` RPC, and the gateway rejects
3131
results from stale instance IDs. The process supervisor keeps the main SSH
32-
session alive until an attached foreground client receives the terminal result
33-
or a bounded detached timeout expires, then reports the result and closes.
32+
session alive until an attached foreground client drains output to the terminal
33+
event or a bounded detached timeout expires. It durably reports the result,
34+
sends the SSH exit status, and only then releases deferred ephemeral cleanup.
3435

3536
## Protocol and Auth
3637

architecture/sandbox.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,8 @@ engine with a gateway policy revision.
447447
- If the supervisor relay drops, the sandbox can keep running, but connect and
448448
exec operations fail until the supervisor registers again.
449449
- If the canonical main process exits, the supervisor drains its retained main
450-
output and reports the normalized result before shutdown. Exit code 0 records
450+
output, durably reports the normalized result, sends the SSH exit status, and
451+
releases deferred ephemeral cleanup before shutdown. Exit code 0 records
451452
`Completed/MainProcessCompleted`; nonzero and signal-normalized exits record
452453
`Error/MainProcessFailed`. Infrastructure failures also use `Error`, with a
453454
distinct condition reason and no fabricated canonical-process result. Runtime

crates/openshell-cli/src/run.rs

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,23 @@ fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>) -> bool {
222222
keep || forward.is_some()
223223
}
224224

225+
fn has_main_process_result(sandbox: &Sandbox) -> bool {
226+
let Some(status) = sandbox.status.as_ref() else {
227+
return false;
228+
};
229+
if status.exit_code.is_none() {
230+
return false;
231+
}
232+
233+
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
234+
phase != SandboxPhase::Error
235+
|| status.conditions.iter().any(|condition| {
236+
condition.r#type == "Ready"
237+
&& condition.status.eq_ignore_ascii_case("false")
238+
&& condition.reason == "MainProcessFailed"
239+
})
240+
}
241+
225242
fn build_sandbox_resource_limits(
226243
cpu: Option<&str>,
227244
memory: Option<&str>,
@@ -761,14 +778,11 @@ pub async fn sandbox_create(
761778
saw_non_ready = true;
762779
}
763780

764-
let has_main_process_result = s
765-
.status
766-
.as_ref()
767-
.is_some_and(|status| status.exit_code.is_some());
781+
let main_process_result = has_main_process_result(&s);
768782
if matches!(
769783
phase,
770784
SandboxPhase::Completed | SandboxPhase::Error | SandboxPhase::Stopped
771-
) && has_main_process_result
785+
) && main_process_result
772786
{
773787
if let Some(d) = display.as_interactive_mut() {
774788
d.clear();
@@ -856,10 +870,7 @@ pub async fn sandbox_create(
856870

857871
// If we exited the loop without hitting the Ready break, finish the display.
858872
let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown);
859-
let final_has_main_process_result = last_sandbox
860-
.status
861-
.as_ref()
862-
.is_some_and(|status| status.exit_code.is_some());
873+
let final_has_main_process_result = has_main_process_result(&last_sandbox);
863874
if !(matches!(
864875
final_phase,
865876
SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped
@@ -7206,13 +7217,14 @@ mod tests {
72067217
use super::{
72077218
PolicyGetView, ProvisioningStep, build_sandbox_resource_limits,
72087219
dockerfile_sources_supported_for_gateway, format_endpoint,
7209-
format_provider_attachment_table, git_sync_files, inferred_provider_type,
7210-
parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs,
7211-
parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
7212-
policy_revision_to_json, provider_profile_allows_empty_credentials,
7213-
provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
7214-
refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan,
7215-
service_expose_status_error, service_url_for_gateway,
7220+
format_provider_attachment_table, git_sync_files, has_main_process_result,
7221+
inferred_provider_type, parse_cli_setting_value, parse_credential_expiry_cli_value,
7222+
parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json,
7223+
parse_secret_material_env_pairs, policy_revision_to_json,
7224+
provider_profile_allows_empty_credentials, provisioning_timeout_message,
7225+
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7226+
sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7227+
service_url_for_gateway,
72167228
};
72177229
use crate::TEST_ENV_LOCK;
72187230
use crate::commands::common::progress_step_from_metadata;
@@ -7751,6 +7763,48 @@ mod tests {
77517763
assert!(sandbox_should_persist(false, Some(&spec)));
77527764
}
77537765

7766+
#[test]
7767+
fn infrastructure_error_with_observed_exit_is_not_a_main_process_result() {
7768+
let mut sandbox = Sandbox {
7769+
status: Some(SandboxStatus {
7770+
exit_code: Some(137),
7771+
conditions: vec![SandboxCondition {
7772+
r#type: "Ready".to_string(),
7773+
status: "False".to_string(),
7774+
reason: "ComputeResourceMissing".to_string(),
7775+
message: "sandbox runtime disappeared".to_string(),
7776+
..Default::default()
7777+
}],
7778+
..Default::default()
7779+
}),
7780+
..Default::default()
7781+
};
7782+
sandbox.set_phase(SandboxPhase::Error as i32);
7783+
7784+
assert!(!has_main_process_result(&sandbox));
7785+
}
7786+
7787+
#[test]
7788+
fn main_process_failed_condition_identifies_command_result() {
7789+
let mut sandbox = Sandbox {
7790+
status: Some(SandboxStatus {
7791+
exit_code: Some(7),
7792+
conditions: vec![SandboxCondition {
7793+
r#type: "Ready".to_string(),
7794+
status: "False".to_string(),
7795+
reason: "MainProcessFailed".to_string(),
7796+
message: "canonical main process exited with status 7".to_string(),
7797+
..Default::default()
7798+
}],
7799+
..Default::default()
7800+
}),
7801+
..Default::default()
7802+
};
7803+
sandbox.set_phase(SandboxPhase::Error as i32);
7804+
7805+
assert!(has_main_process_result(&sandbox));
7806+
}
7807+
77547808
#[test]
77557809
fn resolve_from_classifies_existing_dockerfile_path() {
77567810
let temp = tempfile::tempdir().expect("failed to create tempdir");

crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ struct SandboxState {
4747
deleted_names: Arc<Mutex<Vec<Vec<String>>>>,
4848
create_requests: Arc<Mutex<Vec<CreateSandboxRequest>>>,
4949
vm_error_after_started: Arc<AtomicBool>,
50+
vm_error_with_observed_exit: Arc<AtomicBool>,
5051
vm_slow_progress_before_ready: Arc<AtomicBool>,
5152
vm_log_churn_before_ready: Arc<AtomicBool>,
5253
global_settings: Arc<Mutex<HashMap<String, SettingValue>>>,
@@ -401,6 +402,10 @@ impl OpenShell for TestOpenShell {
401402
let sandbox_id = request.into_inner().id;
402403
let (tx, rx) = mpsc::channel(4);
403404
let vm_error_after_started = self.state.vm_error_after_started.load(Ordering::SeqCst);
405+
let vm_error_with_observed_exit = self
406+
.state
407+
.vm_error_with_observed_exit
408+
.load(Ordering::SeqCst);
404409
let vm_slow_progress_before_ready = self
405410
.state
406411
.vm_slow_progress_before_ready
@@ -437,6 +442,9 @@ impl OpenShell for TestOpenShell {
437442
..provisioning.clone()
438443
};
439444
error.set_phase(SandboxPhase::Error as i32);
445+
if vm_error_with_observed_exit {
446+
error.status.as_mut().unwrap().exit_code = Some(137);
447+
}
440448
let mut ready = provisioning.clone();
441449
ready.set_phase(SandboxPhase::Ready as i32);
442450

@@ -1550,6 +1558,44 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() {
15501558
assert!(!rendered.contains("timed out"));
15511559
}
15521560

1561+
#[tokio::test]
1562+
async fn sandbox_create_preserves_vm_error_when_exit_code_is_observed() {
1563+
let server = run_server().await;
1564+
server
1565+
.openshell
1566+
.state
1567+
.vm_error_after_started
1568+
.store(true, Ordering::SeqCst);
1569+
server
1570+
.openshell
1571+
.state
1572+
.vm_error_with_observed_exit
1573+
.store(true, Ordering::SeqCst);
1574+
let fake_ssh_dir = tempfile::tempdir().unwrap();
1575+
let xdg_dir = tempfile::tempdir().unwrap();
1576+
let _env = test_env(&fake_ssh_dir, &xdg_dir);
1577+
let tls = test_tls(&server);
1578+
install_fake_ssh(&fake_ssh_dir);
1579+
1580+
let err = run::sandbox_create(
1581+
&server.endpoint,
1582+
"openshell",
1583+
run::SandboxCreateConfig {
1584+
name: Some("vm-error-with-exit"),
1585+
command: &["echo".into(), "OK".into()],
1586+
..test_config()
1587+
},
1588+
"default",
1589+
&tls,
1590+
)
1591+
.await
1592+
.expect_err("an observed process exit must not hide the infrastructure error");
1593+
1594+
let rendered = err.to_string();
1595+
assert!(rendered.contains("sandbox entered error phase while provisioning"));
1596+
assert!(rendered.contains("ProcessExited: VM process exited with status 0"));
1597+
}
1598+
15531599
#[tokio::test]
15541600
async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() {
15551601
let server = run_server().await;

crates/openshell-sandbox/src/lib.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -866,13 +866,16 @@ pub async fn run_sandbox(
866866
openshell_supervisor_process::run::SidecarExitReport,
867867
>(1);
868868
tokio::spawn(async move {
869-
while let Some((instance_id, exit_code, ack)) = rx.recv().await {
869+
while let Some((instance_id, exit_code, defer_ephemeral_cleanup, ack)) =
870+
rx.recv().await
871+
{
870872
let (durable_tx, durable_rx) = tokio::sync::oneshot::channel();
871873
*exit_ack.lock().await = Some((instance_id.clone(), durable_tx));
872874
let result = match sidecar_control::send_main_process_exited(
873875
&writer,
874876
instance_id,
875877
exit_code,
878+
defer_ephemeral_cleanup,
876879
)
877880
.await
878881
{
@@ -1177,7 +1180,6 @@ fn spawn_sidecar_entrypoint_handler(
11771180
let terminating = Arc::new(AtomicBool::new(false));
11781181
while let Some(started) = entrypoint_rx.recv().await {
11791182
if let Some(exit_code) = started.exit_code {
1180-
terminating.store(true, Ordering::Release);
11811183
if let (Some(endpoint), Some(id)) =
11821184
(openshell_endpoint.as_ref(), sandbox_id.as_ref())
11831185
{
@@ -1188,6 +1190,7 @@ fn spawn_sidecar_entrypoint_handler(
11881190
id,
11891191
&started.instance_id,
11901192
exit_code,
1193+
started.defer_ephemeral_cleanup,
11911194
)
11921195
.await
11931196
{
@@ -1203,6 +1206,10 @@ fn spawn_sidecar_entrypoint_handler(
12031206
publisher.publish_main_process_exit_ack(started.instance_id.clone());
12041207
}
12051208
}
1209+
if started.defer_ephemeral_cleanup {
1210+
continue;
1211+
}
1212+
terminating.store(true, Ordering::Release);
12061213
break;
12071214
}
12081215
entrypoint_pid.store(started.pid, Ordering::Release);

0 commit comments

Comments
 (0)