Skip to content

Commit 1477ca7

Browse files
committed
fix(sandbox): stabilize terminal attachment checks
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 627ac18 commit 1477ca7

4 files changed

Lines changed: 142 additions & 19 deletions

File tree

crates/openshell-cli/src/run.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,7 @@ pub async fn sandbox_create(
10541054
if detach {
10551055
return Ok(0);
10561056
}
1057-
let connect_result = crate::ssh::sandbox_connect_without_exec(
1057+
let connect_result = crate::ssh::sandbox_connect_terminal_main(
10581058
&effective_server,
10591059
&sandbox_name,
10601060
&effective_tls,

crates/openshell-cli/src/ssh.rs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
2929
use tokio::net::TcpStream;
3030
use tokio::process::{Child, Command as TokioCommand};
3131
use tokio_stream::wrappers::ReceiverStream;
32+
use tonic::Code;
3233

3334
/// Time budget for the local listener to become reachable after `ssh` starts.
3435
/// This is a user-visible readiness deadline for both foreground and background
@@ -39,6 +40,10 @@ const FORWARD_LISTENER_PROBE_INTERVAL: Duration = Duration::from_millis(50);
3940
/// Per-attempt connect timeout, so one hung probe cannot consume the whole
4041
/// grace period.
4142
const FORWARD_LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
43+
/// Time budget for the supervisor relay to register after a fast canonical
44+
/// command has already reported its terminal result.
45+
const TERMINAL_RELAY_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5);
46+
const TERMINAL_RELAY_REGISTRATION_INTERVAL: Duration = Duration::from_millis(50);
4247

4348
#[derive(Clone, Copy, Debug)]
4449
pub enum Editor {
@@ -79,6 +84,7 @@ async fn ssh_session_config(
7984
name: &str,
8085
tls: &TlsOptions,
8186
workspace: &str,
87+
terminal_relay_registration_timeout: Option<Duration>,
8288
) -> Result<SshSessionConfig> {
8389
let mut client = grpc_client(server, tls).await?;
8490

@@ -94,12 +100,26 @@ async fn ssh_session_config(
94100
.sandbox
95101
.ok_or_else(|| miette::miette!("sandbox not found"))?;
96102

97-
let response = client
98-
.create_ssh_session(CreateSshSessionRequest {
99-
sandbox_id: sandbox.object_id().to_string(),
100-
})
101-
.await
102-
.into_diagnostic()?;
103+
let relay_registration_deadline =
104+
terminal_relay_registration_timeout.map(|timeout| tokio::time::Instant::now() + timeout);
105+
let response = loop {
106+
match client
107+
.create_ssh_session(CreateSshSessionRequest {
108+
sandbox_id: sandbox.object_id().to_string(),
109+
})
110+
.await
111+
{
112+
Ok(response) => break response,
113+
Err(status)
114+
if status.code() == Code::FailedPrecondition
115+
&& relay_registration_deadline
116+
.is_some_and(|deadline| tokio::time::Instant::now() < deadline) =>
117+
{
118+
tokio::time::sleep(TERMINAL_RELAY_REGISTRATION_INTERVAL).await;
119+
}
120+
Err(status) => return Err(status).into_diagnostic(),
121+
}
122+
};
103123
let session = response.into_inner();
104124
validate_ssh_session_response(&session)
105125
.map_err(|err| miette::miette!("gateway returned invalid SSH session response: {err}"))?;
@@ -257,8 +277,16 @@ async fn sandbox_connect_with_mode(
257277
tls: &TlsOptions,
258278
replace_process: bool,
259279
workspace: &str,
280+
terminal_relay_registration_timeout: Option<Duration>,
260281
) -> Result<i32> {
261-
let session = ssh_session_config(server, name, tls, workspace).await?;
282+
let session = ssh_session_config(
283+
server,
284+
name,
285+
tls,
286+
workspace,
287+
terminal_relay_registration_timeout,
288+
)
289+
.await?;
262290

263291
let mut command = ssh_base_command(&session.proxy_command);
264292
if session.main_terminal {
@@ -290,7 +318,7 @@ pub async fn sandbox_connect(
290318
tls: &TlsOptions,
291319
workspace: &str,
292320
) -> Result<i32> {
293-
sandbox_connect_with_mode(server, name, tls, true, workspace).await
321+
sandbox_connect_with_mode(server, name, tls, true, workspace, None).await
294322
}
295323

296324
pub(crate) async fn sandbox_connect_without_exec(
@@ -299,7 +327,24 @@ pub(crate) async fn sandbox_connect_without_exec(
299327
tls: &TlsOptions,
300328
workspace: &str,
301329
) -> Result<i32> {
302-
sandbox_connect_with_mode(server, name, tls, false, workspace).await
330+
sandbox_connect_with_mode(server, name, tls, false, workspace, None).await
331+
}
332+
333+
pub(crate) async fn sandbox_connect_terminal_main(
334+
server: &str,
335+
name: &str,
336+
tls: &TlsOptions,
337+
workspace: &str,
338+
) -> Result<i32> {
339+
sandbox_connect_with_mode(
340+
server,
341+
name,
342+
tls,
343+
false,
344+
workspace,
345+
Some(TERMINAL_RELAY_REGISTRATION_TIMEOUT),
346+
)
347+
.await
303348
}
304349

305350
pub async fn sandbox_connect_editor(
@@ -310,7 +355,7 @@ pub async fn sandbox_connect_editor(
310355
tls: &TlsOptions,
311356
workspace: &str,
312357
) -> Result<()> {
313-
let session = ssh_session_config(server, name, tls, workspace).await?;
358+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
314359
let workspace_root = discover_workspace_root(&session).await?;
315360

316361
let host_alias = host_alias(name, workspace);
@@ -339,7 +384,7 @@ pub async fn sandbox_forward(
339384
) -> Result<()> {
340385
openshell_core::forward::check_port_available(spec)?;
341386

342-
let session = ssh_session_config(server, name, tls, workspace).await?;
387+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
343388

344389
let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command));
345390
command
@@ -554,7 +599,7 @@ async fn sandbox_exec_with_mode(
554599
return Err(miette::miette!("no command provided"));
555600
}
556601

557-
let session = ssh_session_config(server, name, tls, workspace).await?;
602+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
558603
let mut ssh = ssh_base_command(&session.proxy_command);
559604

560605
if tty {
@@ -767,7 +812,7 @@ async fn ssh_tar_upload(
767812
tls: &TlsOptions,
768813
workspace: &str,
769814
) -> Result<()> {
770-
let session = ssh_session_config(server, name, tls, workspace).await?;
815+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
771816

772817
let dest_dir = dest_dir.unwrap_or(".");
773818
let escaped_dest = shell_escape(dest_dir);
@@ -1200,7 +1245,7 @@ pub async fn sandbox_sync_down(
12001245
tls: &TlsOptions,
12011246
workspace: &str,
12021247
) -> Result<()> {
1203-
let session = ssh_session_config(server, name, tls, workspace).await?;
1248+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
12041249
let sandbox_path = resolve_sandbox_source_path(&session, sandbox_path).await?;
12051250
let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?;
12061251

@@ -1482,7 +1527,7 @@ pub async fn sandbox_ssh_proxy_by_name(
14821527
tls: &TlsOptions,
14831528
workspace: &str,
14841529
) -> Result<()> {
1485-
let session = ssh_session_config(server, name, tls, workspace).await?;
1530+
let session = ssh_session_config(server, name, tls, workspace, None).await?;
14861531
sandbox_ssh_proxy(
14871532
&session.gateway_url,
14881533
&session.sandbox_id,

crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ struct SandboxState {
5151
vm_error_with_observed_exit: Arc<AtomicBool>,
5252
vm_slow_progress_before_ready: Arc<AtomicBool>,
5353
vm_log_churn_before_ready: Arc<AtomicBool>,
54+
terminal_before_relay: Arc<AtomicBool>,
55+
ssh_session_failures_remaining: Arc<AtomicUsize>,
56+
ssh_session_requests: Arc<AtomicUsize>,
5457
global_settings: Arc<Mutex<HashMap<String, SettingValue>>>,
5558
gateway_config_requests: Arc<AtomicUsize>,
5659
}
@@ -244,6 +247,19 @@ impl OpenShell for TestOpenShell {
244247
&self,
245248
request: tonic::Request<CreateSshSessionRequest>,
246249
) -> Result<Response<CreateSshSessionResponse>, Status> {
250+
self.state
251+
.ssh_session_requests
252+
.fetch_add(1, Ordering::SeqCst);
253+
if self
254+
.state
255+
.ssh_session_failures_remaining
256+
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
257+
remaining.checked_sub(1)
258+
})
259+
.is_ok()
260+
{
261+
return Err(Status::failed_precondition("sandbox is not ready"));
262+
}
247263
let sandbox_id = request.into_inner().sandbox_id;
248264
Ok(Response::new(CreateSshSessionResponse {
249265
sandbox_id,
@@ -426,6 +442,7 @@ impl OpenShell for TestOpenShell {
426442
.vm_slow_progress_before_ready
427443
.load(Ordering::SeqCst);
428444
let vm_log_churn_before_ready = self.state.vm_log_churn_before_ready.load(Ordering::SeqCst);
445+
let terminal_before_relay = self.state.terminal_before_relay.load(Ordering::SeqCst);
429446

430447
tokio::spawn(async move {
431448
let mut provisioning = Sandbox {
@@ -462,6 +479,12 @@ impl OpenShell for TestOpenShell {
462479
}
463480
let mut ready = provisioning.clone();
464481
ready.set_phase(SandboxPhase::Ready as i32);
482+
let mut completed = provisioning.clone();
483+
completed.status = Some(SandboxStatus {
484+
exit_code: Some(0),
485+
..SandboxStatus::default()
486+
});
487+
completed.set_phase(SandboxPhase::Completed as i32);
465488

466489
let _ = tx
467490
.send(Ok(SandboxStreamEvent {
@@ -511,6 +534,14 @@ impl OpenShell for TestOpenShell {
511534
.await;
512535
return;
513536
}
537+
if terminal_before_relay {
538+
let _ = tx
539+
.send(Ok(SandboxStreamEvent {
540+
payload: Some(sandbox_stream_event::Payload::Sandbox(completed)),
541+
}))
542+
.await;
543+
return;
544+
}
514545
if vm_slow_progress_before_ready {
515546
tokio::time::sleep(Duration::from_millis(600)).await;
516547
let _ = tx
@@ -1713,6 +1744,50 @@ async fn sandbox_create_times_out_when_only_logs_arrive() {
17131744
assert!(err.to_string().contains("sandbox provisioning timed out"));
17141745
}
17151746

1747+
#[tokio::test]
1748+
async fn sandbox_create_retries_terminal_attachment_until_relay_registers() {
1749+
let server = run_server().await;
1750+
server
1751+
.openshell
1752+
.state
1753+
.terminal_before_relay
1754+
.store(true, Ordering::SeqCst);
1755+
server
1756+
.openshell
1757+
.state
1758+
.ssh_session_failures_remaining
1759+
.store(1, Ordering::SeqCst);
1760+
let fake_ssh_dir = tempfile::tempdir().unwrap();
1761+
let xdg_dir = tempfile::tempdir().unwrap();
1762+
let _env = test_env(&fake_ssh_dir, &xdg_dir);
1763+
let tls = test_tls(&server);
1764+
install_fake_ssh(&fake_ssh_dir);
1765+
1766+
let exit_code = run::sandbox_create(
1767+
&server.endpoint,
1768+
"openshell",
1769+
run::SandboxCreateConfig {
1770+
name: Some("fast-command"),
1771+
command: &["echo".into(), "OK".into()],
1772+
..test_config()
1773+
},
1774+
"default",
1775+
&tls,
1776+
)
1777+
.await
1778+
.expect("sandbox create should wait for the declared terminal attachment relay");
1779+
1780+
assert_eq!(exit_code, 0);
1781+
assert_eq!(
1782+
server
1783+
.openshell
1784+
.state
1785+
.ssh_session_requests
1786+
.load(Ordering::SeqCst),
1787+
2
1788+
);
1789+
}
1790+
17161791
#[tokio::test]
17171792
async fn sandbox_create_deletes_command_sessions_with_no_keep() {
17181793
let server = run_server().await;

sdk/go/openshell/v1/internal/converter/coverage_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) {
6363
"current_policy_version": true,
6464
"exit_code": true,
6565
}
66-
// The instance ID is an internal gateway/supervisor fencing token exposed
67-
// only through the raw protobuf API.
68-
skipped := fieldSet{"main_process_instance_id": true}
66+
// These fields coordinate internal gateway/supervisor lifecycle fencing and
67+
// terminal delivery. They are exposed only through the raw protobuf API.
68+
skipped := fieldSet{
69+
"main_process_instance_id": true,
70+
"main_process_exit_finalized": true,
71+
}
6972

7073
assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, skipped)
7174
}

0 commit comments

Comments
 (0)