@@ -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+
251257impl 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+
16401697fn 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 ( ) ) ;
0 commit comments