@@ -2260,10 +2260,12 @@ fn forward_requires_session_token(status: &Status) -> bool {
22602260 && status. message ( ) . contains ( "authorization_token is required" )
22612261}
22622262
2263- /// Confirm the sandbox still exists, returning the replica that owns it.
2263+ /// Confirm the sandbox can still serve the forward, returning the replica that
2264+ /// owns it.
22642265///
22652266/// The gateway decides whether each connection may reach the sandbox; this
2266- /// only stops a forward whose sandbox is gone or going away.
2267+ /// only stops a forward whose sandbox is gone or is stopping, stopped, or
2268+ /// being deleted, since no connection can succeed until a new command acts.
22672269async fn fetch_sandbox_for_forward (
22682270 client : & mut crate :: tls:: GrpcClient ,
22692271 name : & str ,
@@ -2293,15 +2295,25 @@ async fn fetch_sandbox_for_forward(
22932295 . sandbox
22942296 . ok_or_else ( || miette:: miette!( "sandbox '{name}' not found" ) ) ?;
22952297
2296- if SandboxPhase :: try_from ( sandbox. phase ( ) ) == Ok ( SandboxPhase :: Deleting ) {
2298+ if forward_ended_by_phase ( sandbox. phase ( ) ) {
22972299 return Err ( miette:: miette!(
2298- "sandbox '{name}' is being deleted; stopping service forward"
2300+ "sandbox '{name}' is no longer available (phase: {}); stopping service forward" ,
2301+ phase_name( sandbox. phase( ) )
22992302 ) ) ;
23002303 }
23012304
23022305 Ok ( replica)
23032306}
23042307
2308+ /// Phases in which no forwarded connection can succeed until another command
2309+ /// starts or recreates the sandbox.
2310+ fn forward_ended_by_phase ( phase : i32 ) -> bool {
2311+ matches ! (
2312+ SandboxPhase :: try_from( phase) ,
2313+ Ok ( SandboxPhase :: Stopping | SandboxPhase :: Stopped | SandboxPhase :: Deleting )
2314+ )
2315+ }
2316+
23052317#[ derive( Debug ) ]
23062318struct ForwardTcpConnectionError {
23072319 message : String ,
@@ -2569,12 +2581,21 @@ impl Drop for TaskGuard {
25692581}
25702582
25712583/// The gateway decides whether a sandbox can be used. When it refuses, name the
2572- /// phase this command saw so the user knows what to wait for .
2584+ /// phase this command saw and what would make the sandbox usable again .
25732585fn exec_start_error ( status : Status , sandbox : & Sandbox ) -> miette:: Report {
25742586 if status. code ( ) == Code :: FailedPrecondition && status. message ( ) == "sandbox is not ready" {
2587+ let name = sandbox. object_name ( ) ;
2588+ let hint = match SandboxPhase :: try_from ( sandbox. phase ( ) ) {
2589+ Ok ( SandboxPhase :: Stopping | SandboxPhase :: Stopped ) => {
2590+ format ! ( "start it with `openshell sandbox start {name}`" )
2591+ }
2592+ Ok ( SandboxPhase :: Completed | SandboxPhase :: Error ) => {
2593+ "its main process exited and its supervisor is no longer connected" . to_string ( )
2594+ }
2595+ _ => "wait for it to reach Ready state" . to_string ( ) ,
2596+ } ;
25752597 return miette:: miette!(
2576- "sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state" ,
2577- sandbox. object_name( ) ,
2598+ "sandbox '{name}' is not ready (phase: {}); {hint}" ,
25782599 phase_name( sandbox. phase( ) )
25792600 ) ;
25802601 }
@@ -7129,14 +7150,14 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String
71297150mod tests {
71307151 use super :: {
71317152 ForwardTcpConnectionError , PolicyGetView , ProvisioningStep , build_sandbox_resource_limits,
7132- format_endpoint, format_log_line, forward_requires_session_token , git_sync_files ,
7133- has_main_process_result , parse_cli_setting_value , parse_credential_expiry_cli_value ,
7134- parse_driver_config_json , parse_env_from_pairs , parse_secret_material_env_pairs ,
7135- policy_revision_list_json , policy_revision_to_json , proto_execution_timeout ,
7136- provisioning_timeout_message , ready_false_condition_message , relay_local_socket ,
7137- resolve_from , rootfs_tar_sources_supported_for_gateway , sandbox_should_persist ,
7138- sandbox_upload_plan , service_endpoint_to_json , service_status_error ,
7139- service_url_for_gateway, workspace_member_to_json,
7153+ format_endpoint, format_log_line, forward_ended_by_phase , forward_requires_session_token ,
7154+ git_sync_files , has_main_process_result , parse_cli_setting_value ,
7155+ parse_credential_expiry_cli_value , parse_driver_config_json , parse_env_from_pairs ,
7156+ parse_secret_material_env_pairs , policy_revision_list_json , policy_revision_to_json ,
7157+ proto_execution_timeout , provisioning_timeout_message , ready_false_condition_message ,
7158+ relay_local_socket , resolve_from , rootfs_tar_sources_supported_for_gateway ,
7159+ sandbox_should_persist , sandbox_upload_plan , service_endpoint_to_json ,
7160+ service_status_error , service_url_for_gateway, workspace_member_to_json,
71407161 } ;
71417162 use openshell_core:: proto:: TcpForwardFrame ;
71427163
@@ -9503,4 +9524,27 @@ mod tests {
95039524 "authorization_token is required for ForwardTcp"
95049525 ) ) ) ;
95059526 }
9527+
9528+ #[ test]
9529+ fn forward_leaves_reachability_after_exit_to_the_gateway ( ) {
9530+ use openshell_core:: proto:: SandboxPhase ;
9531+
9532+ for phase in [
9533+ SandboxPhase :: Stopping ,
9534+ SandboxPhase :: Stopped ,
9535+ SandboxPhase :: Deleting ,
9536+ ] {
9537+ assert ! ( forward_ended_by_phase( phase as i32 ) , "{phase:?}" ) ;
9538+ }
9539+ for phase in [
9540+ SandboxPhase :: Provisioning ,
9541+ SandboxPhase :: Starting ,
9542+ SandboxPhase :: Ready ,
9543+ SandboxPhase :: Completed ,
9544+ SandboxPhase :: Error ,
9545+ SandboxPhase :: Suspended ,
9546+ ] {
9547+ assert ! ( !forward_ended_by_phase( phase as i32 ) , "{phase:?}" ) ;
9548+ }
9549+ }
95069550}
0 commit comments