Skip to content

Commit eeca747

Browse files
committed
feat(sandbox): SandboxPhase::Stopped distinguishes intentional stop from crash (#5)
Add a sixth `SandboxPhase` variant so callers can tell an explicit `openshell sandbox stop` apart from a container that crashed. Previously both surfaced as `Error`, leaving consumers (the openlock reattach path in particular) unable to decide whether to resume or surface the failure. Implementation: `stop_sandbox` stamps an `openshell.io/stop-requested` label on the persisted sandbox before invoking the driver; the watch loop's `apply_sandbox_update_locked` then maps the ensuing `ContainerExited` Ready=false condition to `Stopped` instead of `Error` when the label is present. `start_sandbox` clears the label only after a successful resume so failed resumes don't get misclassified. The supervisor-session reconciler skips sandboxes already in `Stopped` to avoid silently reanimating them. Six unit tests cover the new behavior: stamping, clearing, persistence when backend is missing, the error→stopped override, and the no-label case still surfacing as Error.
1 parent 144f581 commit eeca747

3 files changed

Lines changed: 230 additions & 6 deletions

File tree

crates/openshell-cli/src/run.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ fn phase_name(phase: i32) -> &'static str {
8585
Ok(SandboxPhase::Ready) => "Ready",
8686
Ok(SandboxPhase::Error) => "Error",
8787
Ok(SandboxPhase::Deleting) => "Deleting",
88+
Ok(SandboxPhase::Stopped) => "Stopped",
8889
Ok(SandboxPhase::Unknown) | Err(_) => "Unknown",
8990
}
9091
}
@@ -3212,7 +3213,7 @@ pub async fn sandbox_list(
32123213
Ok(SandboxPhase::Ready) => phase.green().to_string(),
32133214
Ok(SandboxPhase::Error) => phase.red().to_string(),
32143215
Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(),
3215-
Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(),
3216+
Ok(SandboxPhase::Deleting | SandboxPhase::Stopped) => phase.dimmed().to_string(),
32163217
_ => phase.to_string(),
32173218
};
32183219
let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms));

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

Lines changed: 224 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ impl fmt::Debug for ComputeRuntime {
248248
}
249249
}
250250

251+
/// Label stamped onto a Sandbox's metadata by `stop_sandbox` and cleared by
252+
/// `start_sandbox`. When the label is present and the watch loop derives
253+
/// `Error` (because the container exited), the phase is overridden to
254+
/// `Stopped` so callers can distinguish a user-requested halt from a crash.
255+
const STOP_REQUESTED_LABEL: &str = "openshell.io/stop-requested";
256+
251257
impl ComputeRuntime {
252258
#[allow(clippy::too_many_arguments)]
253259
async fn from_driver(
@@ -570,7 +576,9 @@ impl ComputeRuntime {
570576

571577
/// Stop the compute resource backing a sandbox without removing the
572578
/// sandbox record. Workspace volume and provider links survive. Phase
573-
/// is left to the watch loop to update as the backend transitions.
579+
/// is left to the watch loop to update as the backend transitions —
580+
/// but the runtime stamps a `stop-requested` label first so the watch
581+
/// loop maps the ensuing `ContainerExited` to `Stopped`, not `Error`.
574582
/// Idempotent: a missing or already-stopped backend resource is not
575583
/// an error.
576584
pub async fn stop_sandbox(&self, name: &str) -> Result<(), Status> {
@@ -584,6 +592,20 @@ impl ComputeRuntime {
584592
return Err(Status::not_found("sandbox not found"));
585593
};
586594

595+
let _ = self
596+
.store
597+
.update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |s| {
598+
if let Some(metadata) = s.metadata.as_mut() {
599+
metadata
600+
.labels
601+
.insert(STOP_REQUESTED_LABEL.to_string(), "true".to_string());
602+
}
603+
})
604+
.await
605+
.map_err(|e| {
606+
warn!(sandbox_name = %name, error = %e, "Failed to stamp stop-requested label");
607+
});
608+
587609
let driver_sandbox = driver_sandbox_from_public(&sandbox);
588610
self.driver
589611
.stop_sandbox(Request::new(DriverStopSandboxRequest {
@@ -616,10 +638,26 @@ impl ComputeRuntime {
616638
));
617639
};
618640

619-
resume
641+
let started = resume
620642
.resume_sandbox(sandbox.object_id(), sandbox.object_name())
621643
.await
622-
.map_err(|err| Status::internal(format!("start sandbox failed: {err}")))
644+
.map_err(|err| Status::internal(format!("start sandbox failed: {err}")))?;
645+
646+
if started {
647+
let _ = self
648+
.store
649+
.update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |s| {
650+
if let Some(metadata) = s.metadata.as_mut() {
651+
metadata.labels.remove(STOP_REQUESTED_LABEL);
652+
}
653+
})
654+
.await
655+
.map_err(|e| {
656+
warn!(sandbox_name = %name, error = %e, "Failed to clear stop-requested label");
657+
});
658+
}
659+
660+
Ok(started)
623661
}
624662

625663
pub fn spawn_watchers(&self) {
@@ -999,6 +1037,14 @@ impl ComputeRuntime {
9991037
phase = SandboxPhase::Ready;
10001038
}
10011039

1040+
// Distinguish user-requested stop from a crash: when `stop_sandbox`
1041+
// stamped the stop-requested label, treat the container's terminal
1042+
// exit as `Stopped` rather than `Error`. The label is cleared by
1043+
// `start_sandbox` on a successful resume.
1044+
if phase == SandboxPhase::Error && sandbox_has_stop_requested_label(sandbox) {
1045+
phase = SandboxPhase::Stopped;
1046+
}
1047+
10021048
let old_phase =
10031049
SandboxPhase::try_from(sandbox.phase).unwrap_or(SandboxPhase::Unknown);
10041050
if old_phase != phase {
@@ -1077,8 +1123,12 @@ impl ComputeRuntime {
10771123
let current_phase =
10781124
SandboxPhase::try_from(sandbox.phase).unwrap_or(SandboxPhase::Unknown);
10791125

1080-
// Skip if sandbox is in terminal state
1081-
if current_phase == SandboxPhase::Deleting || current_phase == SandboxPhase::Error {
1126+
// Skip if sandbox is in terminal state. Stopped is intentional;
1127+
// a stale supervisor session event must not silently reanimate it.
1128+
if matches!(
1129+
current_phase,
1130+
SandboxPhase::Deleting | SandboxPhase::Error | SandboxPhase::Stopped
1131+
) {
10821132
return;
10831133
}
10841134

@@ -1637,6 +1687,13 @@ fn public_platform_event_from_driver(event: &DriverPlatformEvent) -> PlatformEve
16371687
}
16381688
}
16391689

1690+
fn sandbox_has_stop_requested_label(sandbox: &Sandbox) -> bool {
1691+
sandbox
1692+
.metadata
1693+
.as_ref()
1694+
.is_some_and(|m| m.labels.get(STOP_REQUESTED_LABEL).map(String::as_str) == Some("true"))
1695+
}
1696+
16401697
fn derive_phase(status: Option<&DriverSandboxStatus>) -> SandboxPhase {
16411698
if let Some(status) = status {
16421699
if status.deleting {
@@ -2397,6 +2454,78 @@ mod tests {
23972454
);
23982455
}
23992456

2457+
#[tokio::test]
2458+
async fn apply_sandbox_update_maps_error_to_stopped_when_stop_requested() {
2459+
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
2460+
let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready);
2461+
sandbox
2462+
.metadata
2463+
.as_mut()
2464+
.unwrap()
2465+
.labels
2466+
.insert(STOP_REQUESTED_LABEL.to_string(), "true".to_string());
2467+
runtime.store.put_message(&sandbox).await.unwrap();
2468+
2469+
runtime
2470+
.apply_sandbox_update(DriverSandbox {
2471+
id: "sb-1".to_string(),
2472+
name: "sandbox-a".to_string(),
2473+
namespace: "default".to_string(),
2474+
spec: None,
2475+
status: Some(make_driver_status(make_driver_condition(
2476+
"ContainerExited",
2477+
"Container has exited",
2478+
))),
2479+
})
2480+
.await
2481+
.unwrap();
2482+
2483+
let stored = runtime
2484+
.store
2485+
.get_message::<Sandbox>("sb-1")
2486+
.await
2487+
.unwrap()
2488+
.unwrap();
2489+
assert_eq!(
2490+
SandboxPhase::try_from(stored.phase).unwrap(),
2491+
SandboxPhase::Stopped,
2492+
"watch loop must surface intentional stop as Stopped, not Error"
2493+
);
2494+
}
2495+
2496+
#[tokio::test]
2497+
async fn apply_sandbox_update_keeps_error_when_stop_label_absent() {
2498+
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
2499+
let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready);
2500+
runtime.store.put_message(&sandbox).await.unwrap();
2501+
2502+
runtime
2503+
.apply_sandbox_update(DriverSandbox {
2504+
id: "sb-1".to_string(),
2505+
name: "sandbox-a".to_string(),
2506+
namespace: "default".to_string(),
2507+
spec: None,
2508+
status: Some(make_driver_status(make_driver_condition(
2509+
"ContainerExited",
2510+
"Container crashed",
2511+
))),
2512+
})
2513+
.await
2514+
.unwrap();
2515+
2516+
let stored = runtime
2517+
.store
2518+
.get_message::<Sandbox>("sb-1")
2519+
.await
2520+
.unwrap()
2521+
.unwrap();
2522+
assert_eq!(
2523+
SandboxPhase::try_from(stored.phase).unwrap(),
2524+
SandboxPhase::Error,
2525+
"absent stop-requested label must keep the crash signal as Error"
2526+
);
2527+
}
2528+
24002529
#[tokio::test]
24012530
async fn apply_sandbox_update_promotes_connected_supervisor_session_to_ready() {
24022531
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
@@ -2922,6 +3051,96 @@ mod tests {
29223051
.expect("stop_sandbox should succeed");
29233052
}
29243053

3054+
#[tokio::test]
3055+
async fn stop_sandbox_stamps_stop_requested_label() {
3056+
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
3057+
let sandbox = sandbox_record("sb-1", "live", SandboxPhase::Ready);
3058+
runtime.store.put_message(&sandbox).await.unwrap();
3059+
runtime
3060+
.stop_sandbox("live")
3061+
.await
3062+
.expect("stop_sandbox should succeed");
3063+
3064+
let stored = runtime
3065+
.store
3066+
.get_message::<Sandbox>("sb-1")
3067+
.await
3068+
.unwrap()
3069+
.unwrap();
3070+
assert!(
3071+
sandbox_has_stop_requested_label(&stored),
3072+
"stop_sandbox must stamp the stop-requested label so the watch loop maps ContainerExited→Stopped"
3073+
);
3074+
}
3075+
3076+
#[tokio::test]
3077+
async fn start_sandbox_clears_stop_requested_label_on_success() {
3078+
let resume = Arc::new(RecordingResume::default());
3079+
resume.set_result("sb-1", Ok(true)).await;
3080+
let runtime =
3081+
test_runtime_with_resume(Arc::new(TestDriver::default()), Some(resume.clone())).await;
3082+
3083+
let mut sandbox = sandbox_record("sb-1", "live", SandboxPhase::Stopped);
3084+
sandbox
3085+
.metadata
3086+
.as_mut()
3087+
.unwrap()
3088+
.labels
3089+
.insert(STOP_REQUESTED_LABEL.to_string(), "true".to_string());
3090+
runtime.store.put_message(&sandbox).await.unwrap();
3091+
3092+
let started = runtime
3093+
.start_sandbox("live")
3094+
.await
3095+
.expect("start_sandbox should succeed");
3096+
assert!(started);
3097+
3098+
let stored = runtime
3099+
.store
3100+
.get_message::<Sandbox>("sb-1")
3101+
.await
3102+
.unwrap()
3103+
.unwrap();
3104+
assert!(
3105+
!sandbox_has_stop_requested_label(&stored),
3106+
"start_sandbox must clear the stop-requested label on successful resume"
3107+
);
3108+
}
3109+
3110+
#[tokio::test]
3111+
async fn start_sandbox_preserves_stop_requested_label_when_backend_missing() {
3112+
let resume = Arc::new(RecordingResume::default());
3113+
resume.set_result("sb-1", Ok(false)).await;
3114+
let runtime =
3115+
test_runtime_with_resume(Arc::new(TestDriver::default()), Some(resume.clone())).await;
3116+
3117+
let mut sandbox = sandbox_record("sb-1", "ghost", SandboxPhase::Stopped);
3118+
sandbox
3119+
.metadata
3120+
.as_mut()
3121+
.unwrap()
3122+
.labels
3123+
.insert(STOP_REQUESTED_LABEL.to_string(), "true".to_string());
3124+
runtime.store.put_message(&sandbox).await.unwrap();
3125+
3126+
let started = runtime
3127+
.start_sandbox("ghost")
3128+
.await
3129+
.expect("start_sandbox should succeed");
3130+
assert!(!started);
3131+
3132+
let stored = runtime
3133+
.store
3134+
.get_message::<Sandbox>("sb-1")
3135+
.await
3136+
.unwrap()
3137+
.unwrap();
3138+
assert!(
3139+
sandbox_has_stop_requested_label(&stored),
3140+
"label must persist when resume hook reports backend missing — sandbox is still stopped"
3141+
);
3142+
}
3143+
29253144
#[tokio::test]
29263145
async fn start_sandbox_returns_not_found_when_sandbox_missing() {
29273146
let resume = Arc::new(RecordingResume::default());

proto/openshell.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,10 @@ enum SandboxPhase {
414414
SANDBOX_PHASE_ERROR = 3;
415415
SANDBOX_PHASE_DELETING = 4;
416416
SANDBOX_PHASE_UNKNOWN = 5;
417+
// Compute backend is intentionally stopped (explicit Stop RPC). Distinct
418+
// from Error so reattach paths and external clients can tell a user-
419+
// requested halt from a crash. Cleared by the Start RPC.
420+
SANDBOX_PHASE_STOPPED = 6;
417421
}
418422

419423
// Public platform event exposed on the sandbox watch stream.

0 commit comments

Comments
 (0)