// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 use std::collections::HashMap; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use tracing::{info, warn}; use uuid::Uuid; use openshell_core::proto::{ GatewayMessage, RelayFrame, RelayInit, RelayOpen, Sandbox, SessionAccepted, SshRelayTarget, SupervisorMessage, gateway_message, relay_open, supervisor_message, }; use crate::ServerState; use crate::auth::principal::Principal; const HEARTBEAT_INTERVAL_SECS: u32 = 15; const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10); /// Initial backoff between session-availability polls in `wait_for_session`. const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100); /// Maximum backoff between session-availability polls in `wait_for_session`. const SESSION_WAIT_MAX_BACKOFF: Duration = Duration::from_secs(2); /// Upper bound on unclaimed relay channels across all sandboxes. Caps the /// memory a misbehaving caller can pin by calling `open_relay` repeatedly /// while the supervisor never claims (or isn't responding). Sized generously /// so normal bursts pass through; exceeding it returns `ResourceExhausted`. const MAX_PENDING_RELAYS: usize = 256; /// Upper bound on concurrent unclaimed relay channels for a single sandbox. /// Enforces the same shape per sandbox so one misbehaving sandbox can't /// consume the entire global budget. Sits above the SSH-tunnel per-sandbox /// cap (20) so tunnel-specific limits still fire first for that caller. const MAX_PENDING_RELAYS_PER_SANDBOX: usize = 32; // --------------------------------------------------------------------------- // Session registry // --------------------------------------------------------------------------- /// A live supervisor session handle. struct LiveSession { #[allow(dead_code)] sandbox_id: String, /// Uniquely identifies this session instance. Used by cleanup to avoid /// removing a session that has since been superseded by a reconnect. session_id: String, tx: mpsc::Sender, /// Fires when this session is superseded by a reconnect so the old session /// task can exit promptly — dropping its own `tx` clone and closing the /// outbound stream. Without this, a concurrent `open_relay` that grabbed /// the old session's `tx` just before supersede could still enqueue a /// `RelayOpen` onto the stale stream and sit until the relay timeout. shutdown: oneshot::Sender<()>, #[allow(dead_code)] connected_at: Instant, } /// Holds a oneshot sender that will deliver the upgraded relay stream or a /// target-open failure reported by the supervisor. type RelayStreamSender = oneshot::Sender>; impl openshell_driver_docker::SupervisorReadiness for SupervisorSessionRegistry { fn is_supervisor_connected(&self, sandbox_id: &str) -> bool { Self::is_connected(self, sandbox_id) } } /// Registry of active supervisor sessions and pending relay channels. #[derive(Default)] pub struct SupervisorSessionRegistry { /// `sandbox_id` -> live session handle. sessions: Mutex>, /// `channel_id` -> oneshot sender for the reverse CONNECT stream. pending_relays: Mutex>, } struct PendingRelay { sender: RelayStreamSender, sandbox_id: String, relay_open: RelayOpen, created_at: Instant, } impl std::fmt::Debug for SupervisorSessionRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let session_count = self.sessions.lock().unwrap().len(); let pending_count = self.pending_relays.lock().unwrap().len(); f.debug_struct("SupervisorSessionRegistry") .field("sessions", &session_count) .field("pending_relays", &pending_count) .finish() } } impl SupervisorSessionRegistry { pub fn new() -> Self { Self::default() } /// Register a live supervisor session for the given sandbox. /// /// If a previous session exists for the same sandbox, its shutdown signal /// is fired so the old session task exits promptly. Returns `true` iff a /// previous session was superseded. pub fn register( &self, sandbox_id: String, session_id: String, tx: mpsc::Sender, shutdown: oneshot::Sender<()>, ) -> bool { let mut sessions = self.sessions.lock().unwrap(); let previous = sessions.remove(&sandbox_id); sessions.insert( sandbox_id.clone(), LiveSession { sandbox_id, session_id, tx, shutdown, connected_at: Instant::now(), }, ); match previous { Some(prev) => { // Best-effort — the old task may have already exited. let _ = prev.shutdown.send(()); true } None => false, } } /// Report whether a live supervisor session is registered for a sandbox. /// /// Used by compute drivers that need to surface "supervisor relay ready" /// through the Ready condition without polling the sandbox runtime. pub fn is_connected(&self, sandbox_id: &str) -> bool { self.sessions.lock().unwrap().contains_key(sandbox_id) } /// Remove the session for a sandbox. fn remove(&self, sandbox_id: &str) { self.sessions.lock().unwrap().remove(sandbox_id); } /// Remove the session only if its `session_id` matches the one we are /// cleaning up. Returns `true` if the entry was removed. /// /// This guards against the supersede race: an old session's task may /// finish long after a new session has taken its place. The old task's /// cleanup must not evict the new registration. fn remove_if_current(&self, sandbox_id: &str, session_id: &str) -> bool { let mut sessions = self.sessions.lock().unwrap(); let is_current = sessions .get(sandbox_id) .is_some_and(|s| s.session_id == session_id); if is_current { sessions.remove(sandbox_id); } is_current } /// Look up the sender for a supervisor session, waiting up to `timeout` /// for it to appear if absent. /// /// Uses exponential backoff (100ms → 2s) while polling the sessions map. async fn wait_for_session( &self, sandbox_id: &str, timeout: Duration, ) -> Result, Status> { let deadline = Instant::now() + timeout; let mut backoff = SESSION_WAIT_INITIAL_BACKOFF; loop { if let Some(tx) = self.lookup_session(sandbox_id) { return Ok(tx); } if Instant::now() + backoff > deadline { return Err(Status::unavailable("supervisor session not connected")); } tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF); } } fn lookup_session(&self, sandbox_id: &str) -> Option> { self.sessions .lock() .unwrap() .get(sandbox_id) .map(|s| s.tx.clone()) } pub fn has_session(&self, sandbox_id: &str) -> bool { self.sessions.lock().unwrap().contains_key(sandbox_id) } fn pending_channel_ids(&self, sandbox_id: &str) -> Vec { self.pending_relays .lock() .unwrap() .iter() .filter(|(_, pending)| pending.sandbox_id == sandbox_id) .map(|(channel_id, _)| channel_id.clone()) .collect() } /// Open a relay channel and return a receiver for the supervisor-side /// stream. /// /// Sends `RelayOpen` over the supervisor's gRPC session and returns a /// oneshot receiver that resolves once the supervisor opens its reverse /// HTTP CONNECT to `/relay/{channel_id}`. /// /// If the session is not currently registered, this method waits up to /// `session_wait_timeout` for it to appear. A session may be temporarily /// absent for several reasons — all of which look identical from here: /// /// - startup race: the sandbox just reported Ready but the supervisor's /// `ConnectSupervisor` gRPC handshake hasn't completed yet /// - transient disconnect: the session was up but got dropped (network /// blip, gateway restart, supervisor restart) and the supervisor is /// in its reconnect backoff loop /// /// Callers pick the timeout based on how much patience the caller needs. /// A first `sandbox connect` right after `sandbox create` may need to /// wait for the supervisor's initial TLS + gRPC handshake (tens of /// seconds on a slow cluster), while mid-lifetime calls typically just /// need to cover a short reconnect window. pub async fn open_relay( &self, sandbox_id: &str, session_wait_timeout: Duration, ) -> Result< ( String, oneshot::Receiver>, ), Status, > { self.open_relay_with_target( sandbox_id, relay_open::Target::Ssh(SshRelayTarget {}), String::new(), session_wait_timeout, ) .await } pub async fn open_relay_with_target( &self, sandbox_id: &str, target: relay_open::Target, service_id: String, session_wait_timeout: Duration, ) -> Result< ( String, oneshot::Receiver>, ), Status, > { let tx = self .wait_for_session(sandbox_id, session_wait_timeout) .await?; let channel_id = Uuid::new_v4().to_string(); let relay_open = RelayOpen { channel_id: channel_id.clone(), target: Some(target), service_id, }; // Register the pending relay before sending RelayOpen to avoid a race. // Both caps are checked and the insert happens under a single lock hold // so two concurrent calls can't both observe "under the cap" and then // both insert past it. let (relay_tx, relay_rx) = oneshot::channel(); { let mut pending = self.pending_relays.lock().unwrap(); if pending.len() >= MAX_PENDING_RELAYS { return Err(Status::resource_exhausted(format!( "gateway relay capacity reached ({MAX_PENDING_RELAYS} in flight)" ))); } let per_sandbox = pending .values() .filter(|p| p.sandbox_id == sandbox_id) .count(); if per_sandbox >= MAX_PENDING_RELAYS_PER_SANDBOX { return Err(Status::resource_exhausted(format!( "per-sandbox relay limit reached ({MAX_PENDING_RELAYS_PER_SANDBOX} in flight for {sandbox_id})" ))); } pending.insert( channel_id.clone(), PendingRelay { sender: relay_tx, sandbox_id: sandbox_id.to_string(), relay_open: relay_open.clone(), created_at: Instant::now(), }, ); } let msg = GatewayMessage { payload: Some(gateway_message::Payload::RelayOpen(relay_open)), }; if tx.send(msg).await.is_err() { // Session dropped between our lookup and send. self.pending_relays.lock().unwrap().remove(&channel_id); return Err(Status::unavailable("supervisor session disconnected")); } Ok((channel_id, relay_rx)) } pub fn fail_pending_relay(&self, channel_id: &str, error: String) -> bool { let pending = self.pending_relays.lock().unwrap().remove(channel_id); if let Some(pending) = pending { let _ = pending.sender.send(Err(Status::unavailable(error))); true } else { false } } /// Claim a pending relay channel. Called by the `/relay/{channel_id}` HTTP handler /// when the supervisor's reverse CONNECT arrives. /// /// Returns the `DuplexStream` half that the supervisor side should read/write. // `tonic::Status` is large but is the API surface of gRPC handlers. #[allow(clippy::result_large_err)] pub fn claim_relay( &self, channel_id: &str, principal: Option<&Principal>, ) -> Result { let pending = { let mut map = self.pending_relays.lock().unwrap(); let pending = map .get(channel_id) .ok_or_else(|| Status::not_found("unknown or expired relay channel"))?; if let Some(principal) = principal && let Err(status) = crate::auth::guard::ensure_sandbox_principal_scope( principal, &pending.sandbox_id, ) { info!( channel_id = %channel_id, sandbox_id = %pending.sandbox_id, "relay stream: rejecting cross-sandbox claim" ); return Err(status); } if pending.created_at.elapsed() > RELAY_PENDING_TIMEOUT { map.remove(channel_id); return Err(Status::deadline_exceeded("relay channel timed out")); } map.remove(channel_id) .expect("pending relay existed before removal") }; // Create a duplex stream pair: one end for the gateway bridge, one for // the supervisor HTTP CONNECT handler. let (gateway_stream, supervisor_stream) = tokio::io::duplex(64 * 1024); // Send the gateway-side stream to the waiter (exec handler or forward handler). if pending.sender.send(Ok(gateway_stream)).is_err() { return Err(Status::internal("relay requester dropped")); } Ok(supervisor_stream) } /// Remove all pending relays that have exceeded the timeout. pub fn reap_expired_relays(&self) { let mut map = self.pending_relays.lock().unwrap(); map.retain(|_, pending| pending.created_at.elapsed() <= RELAY_PENDING_TIMEOUT); } /// Clean up all state for a sandbox (session + pending relays). pub fn cleanup_sandbox(&self, sandbox_id: &str) { self.remove(sandbox_id); } pub async fn replay_pending_relays(&self, sandbox_id: &str, tx: &mpsc::Sender) { for channel_id in self.pending_channel_ids(sandbox_id) { let relay_open = { let pending = self.pending_relays.lock().unwrap(); pending .get(&channel_id) .map(|pending| pending.relay_open.clone()) }; let Some(relay_open) = relay_open else { continue; }; let msg = GatewayMessage { payload: Some(gateway_message::Payload::RelayOpen(relay_open)), }; if tx.send(msg).await.is_err() { warn!(sandbox_id = %sandbox_id, channel_id = %channel_id, "supervisor session: failed to replay pending relay to superseding session"); break; } } } } /// Spawn a background task that periodically reaps expired pending relay /// entries. /// /// Pending entries are normally consumed either when the supervisor opens its /// reverse CONNECT (via `claim_relay`) or by the gateway-side waiter timing /// out. If neither happens — e.g., the supervisor crashed after acknowledging /// `RelayOpen` but before initiating `RelayStream` — the entry would otherwise /// sit in the map indefinitely. This sweeper bounds that leak. pub fn spawn_relay_reaper(state: Arc, interval: Duration) { tokio::spawn(async move { loop { tokio::time::sleep(interval).await; state.supervisor_sessions.reap_expired_relays(); } }); } async fn require_persisted_sandbox( store: &Arc, sandbox_id: &str, ) -> Result<(), Status> { let sandbox = store .get_message::(sandbox_id) .await .map_err(|err| Status::internal(format!("failed to load sandbox: {err}")))?; if sandbox.is_none() { return Err(Status::not_found("sandbox not found")); } Ok(()) } // --------------------------------------------------------------------------- // RelayStream gRPC handler // --------------------------------------------------------------------------- /// Size of chunks read from the gateway-side `DuplexStream` when forwarding /// bytes back to the supervisor over the gRPC response stream. const RELAY_STREAM_CHUNK_SIZE: usize = 16 * 1024; /// Handle a `RelayStream` RPC from a supervisor. /// /// The first inbound `RelayFrame` must carry a `RelayInit` identifying the /// pending relay; subsequent frames carry raw bytes forward to the /// gateway-side waiter. Bytes flowing the other way are chunked and sent as /// `RelayFrame::data` messages back over the response stream. pub async fn handle_relay_stream( registry: &SupervisorSessionRegistry, request: Request>, ) -> Result< Response< Pin> + Send + 'static>>, >, Status, > { let principal = request.extensions().get::().cloned(); let mut inbound = request.into_inner(); // First frame must identify the channel. let first = inbound .message() .await? .ok_or_else(|| Status::invalid_argument("empty RelayStream"))?; let channel_id = match first.payload { Some(openshell_core::proto::relay_frame::Payload::Init(RelayInit { channel_id })) if !channel_id.is_empty() => { channel_id } _ => { return Err(Status::invalid_argument( "first RelayFrame must be init with non-empty channel_id", )); } }; // Claim the pending relay. Consumes the entry — it cannot be reused. let supervisor_side = registry.claim_relay(&channel_id, principal.as_ref())?; info!(channel_id = %channel_id, "relay stream: claimed pending relay, bridging"); let (mut read_half, mut write_half) = tokio::io::split(supervisor_side); // Supervisor → gateway: drain `inbound` and write to the DuplexStream. let channel_id_in = channel_id.clone(); tokio::spawn(async move { loop { match inbound.message().await { Ok(Some(frame)) => { let Some(openshell_core::proto::relay_frame::Payload::Data(data)) = frame.payload else { warn!(channel_id = %channel_id_in, "relay stream: received non-data frame after init"); break; }; if data.is_empty() { continue; } if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut write_half, &data).await { warn!(channel_id = %channel_id_in, error = %e, "relay stream: write to duplex failed"); break; } } Ok(None) => break, Err(e) => { warn!(channel_id = %channel_id_in, error = %e, "relay stream: inbound errored"); break; } } } // Best-effort half-close on the write side so the reader sees EOF. let _ = tokio::io::AsyncWriteExt::shutdown(&mut write_half).await; }); // Gateway → supervisor: read the DuplexStream and emit RelayFrame::data messages. let (out_tx, out_rx) = mpsc::channel::>(16); let channel_id_out = channel_id; tokio::spawn(async move { let mut buf = vec![0u8; RELAY_STREAM_CHUNK_SIZE]; loop { match tokio::io::AsyncReadExt::read(&mut read_half, &mut buf).await { Ok(0) => break, Ok(n) => { let chunk = RelayFrame { payload: Some(openshell_core::proto::relay_frame::Payload::Data( buf[..n].to_vec(), )), }; if out_tx.send(Ok(chunk)).await.is_err() { break; } } Err(e) => { warn!(channel_id = %channel_id_out, error = %e, "relay stream: read from duplex failed"); break; } } } }); let stream = ReceiverStream::new(out_rx); let stream: Pin< Box> + Send + 'static>, > = Box::pin(stream); Ok(Response::new(stream)) } // --------------------------------------------------------------------------- // ConnectSupervisor gRPC handler // --------------------------------------------------------------------------- pub async fn handle_connect_supervisor( state: &Arc, request: Request>, ) -> Result< Response< Pin> + Send + 'static>>, >, Status, > { let principal = request.extensions().get::().cloned(); let mut inbound = request.into_inner(); // Step 1: Wait for SupervisorHello. let hello = match inbound.message().await? { Some(msg) => match msg.payload { Some(supervisor_message::Payload::Hello(hello)) => hello, _ => return Err(Status::invalid_argument("expected SupervisorHello")), }, None => return Err(Status::invalid_argument("stream closed before hello")), }; let sandbox_id = hello.sandbox_id.clone(); if sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } if let Some(principal) = principal.as_ref() { crate::auth::guard::ensure_sandbox_principal_scope(principal, &sandbox_id)?; } require_persisted_sandbox(&state.store, &sandbox_id).await?; let session_id = Uuid::new_v4().to_string(); info!( sandbox_id = %sandbox_id, session_id = %session_id, instance_id = %hello.instance_id, "supervisor session: accepted" ); // Step 2: Create the outbound channel and register the session. let (tx, rx) = mpsc::channel::(64); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let superseded = state.supervisor_sessions.register( sandbox_id.clone(), session_id.clone(), tx.clone(), shutdown_tx, ); if superseded { info!( sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded previous session" ); } // Step 3: Send SessionAccepted. let accepted = GatewayMessage { payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { session_id: session_id.clone(), heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS, })), }; if tx.send(accepted).await.is_err() { // Only evict ourselves — a faster reconnect may already have // superseded this registration. state .supervisor_sessions .remove_if_current(&sandbox_id, &session_id); return Err(Status::internal("failed to send session accepted")); } if superseded { state .supervisor_sessions .replay_pending_relays(&sandbox_id, &tx) .await; } if let Err(err) = state .compute .supervisor_session_connected(&sandbox_id) .await { warn!( sandbox_id = %sandbox_id, session_id = %session_id, error = %err, "supervisor session: failed to mark sandbox ready" ); } else { state.telemetry.sandbox_session_connected(&sandbox_id); } // Step 4: Spawn the session loop that reads inbound messages. let state_clone = Arc::clone(state); let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { run_session_loop( &state_clone, &sandbox_id_clone, &session_id, &tx, &mut inbound, shutdown_rx, ) .await; let still_ours = state_clone .supervisor_sessions .remove_if_current(&sandbox_id_clone, &session_id); if still_ours { info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended"); state_clone .telemetry .sandbox_session_disconnected(&sandbox_id_clone); if let Err(err) = state_clone .compute .supervisor_session_disconnected(&sandbox_id_clone) .await { warn!( sandbox_id = %sandbox_id_clone, session_id = %session_id, error = %err, "supervisor session: failed to mark sandbox disconnected" ); } } else { info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended (already superseded)"); } }); // Return the outbound stream. let stream = ReceiverStream::new(rx); let stream: Pin< Box> + Send + 'static>, > = Box::pin(tokio_stream::StreamExt::map(stream, Ok)); Ok(Response::new(stream)) } async fn run_session_loop( state: &Arc, sandbox_id: &str, session_id: &str, tx: &mpsc::Sender, inbound: &mut tonic::Streaming, mut shutdown_rx: oneshot::Receiver<()>, ) { let heartbeat_interval = Duration::from_secs(u64::from(HEARTBEAT_INTERVAL_SECS)); let mut heartbeat_timer = tokio::time::interval(heartbeat_interval); // Skip the first immediate tick. heartbeat_timer.tick().await; loop { tokio::select! { _ = &mut shutdown_rx => { info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded by reconnect, shutting down"); break; } msg = inbound.message() => { match msg { Ok(Some(msg)) => { handle_supervisor_message(state, sandbox_id, session_id, msg); } Ok(None) => { info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: stream closed by supervisor"); break; } Err(e) => { warn!(sandbox_id = %sandbox_id, session_id = %session_id, error = %e, "supervisor session: stream error"); break; } } } _ = heartbeat_timer.tick() => { let hb = GatewayMessage { payload: Some(gateway_message::Payload::Heartbeat( openshell_core::proto::GatewayHeartbeat {}, )), }; if tx.send(hb).await.is_err() { info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: outbound channel closed"); break; } } } } } fn handle_supervisor_message( state: &Arc, sandbox_id: &str, session_id: &str, msg: SupervisorMessage, ) { match msg.payload { Some(supervisor_message::Payload::Heartbeat(_)) => { // Heartbeat received — nothing to do for now. } Some(supervisor_message::Payload::RelayOpenResult(result)) => { if result.success { info!( sandbox_id = %sandbox_id, session_id = %session_id, channel_id = %result.channel_id, "supervisor session: relay opened successfully" ); } else { let failed = state .supervisor_sessions .fail_pending_relay(&result.channel_id, result.error.clone()); warn!( sandbox_id = %sandbox_id, session_id = %session_id, channel_id = %result.channel_id, error = %result.error, pending_relay_failed = failed, "supervisor session: relay open failed" ); } } Some(supervisor_message::Payload::RelayClose(close)) => { info!( sandbox_id = %sandbox_id, session_id = %session_id, channel_id = %close.channel_id, reason = %close.reason, "supervisor session: relay closed by supervisor" ); } _ => { warn!( sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: unexpected message type" ); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; use crate::persistence::Store; use tokio::io::{AsyncReadExt, AsyncWriteExt}; async fn test_store() -> Arc { Arc::new( Store::connect("sqlite::memory:?cache=shared") .await .expect("in-memory SQLite store should connect"), ) } /// Returns a shutdown sender with its receiver immediately dropped. Tests /// that don't observe the shutdown signal can use this to satisfy the /// `register` signature without the receiver noise. fn make_shutdown() -> oneshot::Sender<()> { oneshot::channel::<()>().0 } fn sandbox_record(id: &str, name: &str) -> Sandbox { Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, }), ..Default::default() } } fn pending_relay( sandbox_id: &str, relay_tx: RelayStreamSender, created_at: Instant, ) -> PendingRelay { PendingRelay { sender: relay_tx, sandbox_id: sandbox_id.to_string(), relay_open: RelayOpen { channel_id: "ch-test".to_string(), target: Some(relay_open::Target::Ssh(SshRelayTarget {})), service_id: String::new(), }, created_at, } } fn sandbox_principal(sandbox_id: &str) -> Principal { Principal::Sandbox(SandboxPrincipal { sandbox_id: sandbox_id.to_string(), source: SandboxIdentitySource::BootstrapJwt { issuer: "openshell-gateway:test".to_string(), }, trust_domain: Some("openshell".to_string()), }) } fn user_principal(subject: &str) -> Principal { Principal::User(UserPrincipal { identity: Identity { subject: subject.to_string(), display_name: None, roles: vec![], scopes: vec![], provider: IdentityProvider::Oidc, }, }) } // ---- registry: register / remove ---- #[test] fn registry_register_and_lookup() { let registry = SupervisorSessionRegistry::new(); let (tx, _rx) = mpsc::channel(1); assert!(!registry.register( "sandbox-1".to_string(), "s1".to_string(), tx, make_shutdown(), )); let sessions = registry.sessions.lock().unwrap(); assert!(sessions.contains_key("sandbox-1")); } #[test] fn registry_supersedes_previous_session() { let registry = SupervisorSessionRegistry::new(); let (tx1, _rx1) = mpsc::channel(1); let (tx2, _rx2) = mpsc::channel(1); assert!(!registry.register( "sandbox-1".to_string(), "s1".to_string(), tx1, make_shutdown(), )); assert!(registry.register( "sandbox-1".to_string(), "s2".to_string(), tx2, make_shutdown(), )); } #[test] fn registry_remove() { let registry = SupervisorSessionRegistry::new(); let (tx, _rx) = mpsc::channel(1); registry.register( "sandbox-1".to_string(), "s1".to_string(), tx, make_shutdown(), ); registry.remove("sandbox-1"); let sessions = registry.sessions.lock().unwrap(); assert!(!sessions.contains_key("sandbox-1")); } #[test] fn remove_if_current_removes_matching_session() { let registry = SupervisorSessionRegistry::new(); let (tx, _rx) = mpsc::channel(1); registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); assert!(registry.remove_if_current("sbx", "s1")); assert!(!registry.sessions.lock().unwrap().contains_key("sbx")); } #[test] fn remove_if_current_ignores_stale_session_id() { let registry = SupervisorSessionRegistry::new(); let (tx_old, _rx_old) = mpsc::channel(1); let (tx_new, _rx_new) = mpsc::channel(1); // Old session registers, then is superseded by a new session. registry.register( "sbx".to_string(), "s-old".to_string(), tx_old, make_shutdown(), ); registry.register( "sbx".to_string(), "s-new".to_string(), tx_new, make_shutdown(), ); // Cleanup from the old session task runs late. It must NOT evict the // newly registered session. assert!(!registry.remove_if_current("sbx", "s-old")); let sessions = registry.sessions.lock().unwrap(); assert!( sessions.contains_key("sbx"), "new session must still be registered" ); assert_eq!(sessions.get("sbx").unwrap().session_id, "s-new"); } #[test] fn remove_if_current_unknown_sandbox_is_noop() { let registry = SupervisorSessionRegistry::new(); assert!(!registry.remove_if_current("sbx-does-not-exist", "s1")); } // ---- open_relay: happy path and wait semantics ---- #[tokio::test] async fn open_relay_sends_relay_open_to_registered_session() { let registry = SupervisorSessionRegistry::new(); let (tx, mut rx) = mpsc::channel(4); registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); let (channel_id, _relay_rx) = registry .open_relay("sbx", Duration::from_secs(1)) .await .expect("open_relay should succeed when session is live"); let msg = rx.recv().await.expect("relay open should be delivered"); match msg.payload { Some(gateway_message::Payload::RelayOpen(open)) => { assert_eq!(open.channel_id, channel_id); assert!(matches!(open.target, Some(relay_open::Target::Ssh(_)))); } other => panic!("expected RelayOpen, got {other:?}"), } } #[tokio::test] async fn open_relay_times_out_without_session() { let registry = SupervisorSessionRegistry::new(); let err = registry .open_relay("missing", Duration::from_millis(50)) .await .expect_err("open_relay should time out"); assert_eq!(err.code(), tonic::Code::Unavailable); } #[tokio::test] async fn open_relay_waits_for_session_to_appear() { let registry = Arc::new(SupervisorSessionRegistry::new()); let registry_for_register = Arc::clone(®istry); // Register the session after a small delay, shorter than the wait. tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(200)).await; let (tx, mut rx) = mpsc::channel::(4); // Keep the receiver alive so the send in open_relay succeeds. tokio::spawn(async move { while rx.recv().await.is_some() {} }); registry_for_register.register( "sbx".to_string(), "s1".to_string(), tx, make_shutdown(), ); }); let result = registry.open_relay("sbx", Duration::from_secs(2)).await; assert!( result.is_ok(), "open_relay should succeed when session arrives mid-wait: {result:?}" ); } #[tokio::test] async fn open_relay_fails_when_session_receiver_dropped() { let registry = SupervisorSessionRegistry::new(); let (tx, rx) = mpsc::channel::(4); registry.register("sbx".to_string(), "s1".to_string(), tx, make_shutdown()); // Simulate the supervisor's stream going away between lookup and send: // the receiver held by `ReceiverStream` is dropped. drop(rx); let err = registry .open_relay("sbx", Duration::from_secs(1)) .await .expect_err("open_relay should fail when mpsc is closed"); assert_eq!(err.code(), tonic::Code::Unavailable); // The pending-relay entry must have been cleaned up on failure. assert!(registry.pending_relays.lock().unwrap().is_empty()); } #[tokio::test] async fn open_relay_rejects_when_global_cap_reached() { let registry = SupervisorSessionRegistry::new(); let (tx, _rx) = mpsc::channel::(8); registry.register( "sbx-a".to_string(), "s-a".to_string(), tx.clone(), make_shutdown(), ); registry.register("sbx-b".to_string(), "s-b".to_string(), tx, make_shutdown()); // Pre-seed pending_relays to exactly the global cap, split across two // sandboxes so neither hits the per-sandbox cap first. { let mut pending = registry.pending_relays.lock().unwrap(); for i in 0..MAX_PENDING_RELAYS { let (oneshot_tx, _) = oneshot::channel(); let sandbox_id = if i % 2 == 0 { "sbx-a" } else { "sbx-b" }; pending.insert( format!("channel-{i}"), pending_relay(sandbox_id, oneshot_tx, Instant::now()), ); } } let err = registry .open_relay("sbx-a", Duration::from_millis(50)) .await .expect_err("open_relay should reject once global cap is reached"); assert_eq!(err.code(), tonic::Code::ResourceExhausted); assert!(err.message().contains("gateway relay capacity")); } #[tokio::test] async fn open_relay_rejects_when_per_sandbox_cap_reached() { let registry = SupervisorSessionRegistry::new(); let (tx, _rx) = mpsc::channel::(8); registry.register("sbx".to_string(), "s".to_string(), tx, make_shutdown()); { let mut pending = registry.pending_relays.lock().unwrap(); for i in 0..MAX_PENDING_RELAYS_PER_SANDBOX { let (oneshot_tx, _) = oneshot::channel(); pending.insert( format!("channel-{i}"), pending_relay("sbx", oneshot_tx, Instant::now()), ); } } let err = registry .open_relay("sbx", Duration::from_millis(50)) .await .expect_err("open_relay should reject when per-sandbox cap is reached"); assert_eq!(err.code(), tonic::Code::ResourceExhausted); assert!(err.message().contains("per-sandbox relay limit")); // A different sandbox still has headroom. let (tx2, _rx2) = mpsc::channel::(8); registry.register( "sbx-other".to_string(), "s-other".to_string(), tx2, make_shutdown(), ); registry .open_relay("sbx-other", Duration::from_millis(50)) .await .expect("different sandbox should still accept new relays"); } #[tokio::test] async fn open_relay_uses_newest_session_after_supersede() { use tokio::sync::mpsc::error::TryRecvError; let registry = SupervisorSessionRegistry::new(); let (tx_old, mut rx_old) = mpsc::channel::(4); let (tx_new, mut rx_new) = mpsc::channel(4); // Hold a clone of the old sender so supersede doesn't close the old // channel — that way try_recv distinguishes "no message sent" from // "channel closed". let _tx_old_alive = tx_old.clone(); registry.register( "sbx".to_string(), "s-old".to_string(), tx_old, make_shutdown(), ); registry.register( "sbx".to_string(), "s-new".to_string(), tx_new, make_shutdown(), ); let (_channel_id, _relay_rx) = registry .open_relay("sbx", Duration::from_secs(1)) .await .expect("open_relay should succeed"); let msg = rx_new .recv() .await .expect("new session should receive RelayOpen"); assert!(matches!( msg.payload, Some(gateway_message::Payload::RelayOpen(_)) )); // The old session must have received no messages — the channel is // still open but empty. match rx_old.try_recv() { Err(TryRecvError::Empty) => {} other => panic!("expected Empty on superseded session, got {other:?}"), } } #[tokio::test] async fn register_signals_shutdown_to_previous_session() { let registry = SupervisorSessionRegistry::new(); let (tx_old, _rx_old) = mpsc::channel::(1); let (tx_new, _rx_new) = mpsc::channel::(1); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); registry.register("sbx".to_string(), "s-old".to_string(), tx_old, shutdown_tx); // Supersede with a new session — register must fire the old session's // shutdown signal so its task can exit and drop its tx clone. let superseded = registry.register( "sbx".to_string(), "s-new".to_string(), tx_new, make_shutdown(), ); assert!(superseded, "second register should report supersede"); // The old session's shutdown receiver must now resolve. shutdown_rx .await .expect("shutdown signal should arrive at superseded session"); } #[tokio::test] async fn replay_pending_relays_reissues_open_to_superseding_session() { let registry = SupervisorSessionRegistry::new(); let (tx_old, mut rx_old) = mpsc::channel::(4); let (tx_new, mut rx_new) = mpsc::channel::(4); registry.register( "sbx".to_string(), "s-old".to_string(), tx_old, make_shutdown(), ); let (channel_id, _relay_rx) = registry .open_relay("sbx", Duration::from_secs(1)) .await .expect("open_relay should succeed"); let original = rx_old .recv() .await .expect("old session should receive initial RelayOpen"); assert!(matches!( original.payload, Some(gateway_message::Payload::RelayOpen(_)) )); let superseded = registry.register( "sbx".to_string(), "s-new".to_string(), tx_new, make_shutdown(), ); assert!(superseded); registry .replay_pending_relays("sbx", ®istry.lookup_session("sbx").unwrap()) .await; let replayed = rx_new .recv() .await .expect("new session should receive replayed RelayOpen"); match replayed.payload { Some(gateway_message::Payload::RelayOpen(open)) => { assert_eq!(open.channel_id, channel_id); } other => panic!("expected RelayOpen on replay, got {other:?}"), } } #[tokio::test] async fn require_persisted_sandbox_rejects_missing_sandbox() { let store = test_store().await; let err = require_persisted_sandbox(&store, "missing") .await .expect_err("missing sandbox should be rejected"); assert_eq!(err.code(), tonic::Code::NotFound); } #[tokio::test] async fn require_persisted_sandbox_accepts_existing_sandbox() { let store = test_store().await; store .put_message(&sandbox_record("sbx-1", "sandbox-one")) .await .expect("sandbox should persist"); require_persisted_sandbox(&store, "sbx-1") .await .expect("persisted sandbox should be accepted"); } // ---- claim_relay: expiry, drop, wiring ---- #[test] fn claim_relay_unknown_channel() { let registry = SupervisorSessionRegistry::new(); let principal = sandbox_principal("sbx-test"); let err = registry .claim_relay("nonexistent", Some(&principal)) .expect_err("should err"); assert_eq!(err.code(), tonic::Code::NotFound); } #[test] fn claim_relay_success() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-1".to_string(), pending_relay("sbx-test", relay_tx, Instant::now()), ); let principal = sandbox_principal("sbx-test"); let result = registry.claim_relay("ch-1", Some(&principal)); assert!(result.is_ok()); assert!(!registry.pending_relays.lock().unwrap().contains_key("ch-1")); } #[test] fn claim_relay_rejects_cross_sandbox_principal_without_consuming_channel() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-cross".to_string(), pending_relay("sbx-owner", relay_tx, Instant::now()), ); let attacker = sandbox_principal("sbx-attacker"); let err = registry .claim_relay("ch-cross", Some(&attacker)) .expect_err("cross-sandbox relay claim must fail"); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!( registry .pending_relays .lock() .unwrap() .contains_key("ch-cross"), "failed cross-sandbox claim must not consume the channel" ); } #[test] fn claim_relay_rejects_user_principal() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-user".to_string(), pending_relay("sbx-owner", relay_tx, Instant::now()), ); let err = registry .claim_relay("ch-user", Some(&user_principal("alice"))) .expect_err("users are not supervisor identities"); assert_eq!(err.code(), tonic::Code::PermissionDenied); } #[tokio::test] async fn relay_open_failure_completes_pending_waiter() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-fail".to_string(), pending_relay("sbx-test", relay_tx, Instant::now()), ); assert!(registry.fail_pending_relay("ch-fail", "target refused".to_string())); assert!( !registry .pending_relays .lock() .unwrap() .contains_key("ch-fail") ); let result = relay_rx.await.expect("failure should wake waiter"); let status = result.expect_err("waiter should receive status failure"); assert_eq!(status.code(), tonic::Code::Unavailable); assert_eq!(status.message(), "target refused"); } #[test] fn claim_relay_expired_returns_deadline_exceeded() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-old".to_string(), pending_relay( "sbx-test", relay_tx, Instant::now() .checked_sub(Duration::from_secs(60)) .expect("test duration should be before now"), ), ); let err = registry .claim_relay("ch-old", Some(&sandbox_principal("sbx-test"))) .expect_err("expired entry must fail"); assert_eq!(err.code(), tonic::Code::DeadlineExceeded); // Entry must have been consumed regardless. assert!( !registry .pending_relays .lock() .unwrap() .contains_key("ch-old") ); } #[test] fn claim_relay_receiver_dropped_returns_internal() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, relay_rx) = oneshot::channel::>(); drop(relay_rx); // Gateway-side waiter has given up already. registry.pending_relays.lock().unwrap().insert( "ch-1".to_string(), pending_relay("sbx-test", relay_tx, Instant::now()), ); let err = registry .claim_relay("ch-1", Some(&sandbox_principal("sbx-test"))) .expect_err("should err when receiver is gone"); assert_eq!(err.code(), tonic::Code::Internal); } #[tokio::test] async fn claim_relay_connects_both_ends() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, relay_rx) = oneshot::channel::>(); registry.pending_relays.lock().unwrap().insert( "ch-io".to_string(), pending_relay("sbx-test", relay_tx, Instant::now()), ); let mut supervisor_side = registry .claim_relay("ch-io", Some(&sandbox_principal("sbx-test"))) .expect("claim should succeed"); let mut gateway_side = relay_rx .await .expect("gateway side should receive result") .expect("gateway side should receive stream"); // Supervisor side writes → gateway side reads. supervisor_side.write_all(b"hello").await.unwrap(); let mut buf = [0u8; 5]; gateway_side.read_exact(&mut buf).await.unwrap(); assert_eq!(&buf, b"hello"); // Gateway side writes → supervisor side reads. gateway_side.write_all(b"world").await.unwrap(); let mut buf = [0u8; 5]; supervisor_side.read_exact(&mut buf).await.unwrap(); assert_eq!(&buf, b"world"); } // ---- reap_expired_relays ---- #[test] fn reap_expired_relays_removes_old_entries() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-old".to_string(), pending_relay( "sbx-test", relay_tx, Instant::now() .checked_sub(Duration::from_secs(60)) .expect("test duration should be before now"), ), ); registry.reap_expired_relays(); assert!( !registry .pending_relays .lock() .unwrap() .contains_key("ch-old") ); } #[test] fn reap_expired_relays_keeps_fresh_entries() { let registry = SupervisorSessionRegistry::new(); let (relay_tx, _relay_rx) = oneshot::channel(); registry.pending_relays.lock().unwrap().insert( "ch-fresh".to_string(), pending_relay("sbx-test", relay_tx, Instant::now()), ); registry.reap_expired_relays(); assert!( registry .pending_relays .lock() .unwrap() .contains_key("ch-fresh") ); } }