Files
OpenShell/crates/openshell-server/src/supervisor_session.rs
Kirit ThadakaandJohn Myers c63ac76d06 feat(telemetry): add anonymous opt-out OpenShell usage telemetry (#1433)
* feat(telemetry): add anonymous opt-out usage telemetry

Signed-off-by: Kirit93 <kthadaka@nvidia.com>

* Removed enums from schema

Signed-off-by: Kirit Thadaka <kthadaka@nvidia.com>

* Updated telemetry URL

Signed-off-by: Kirit93 <kthadaka@nvidia.com>

* ci(kubernetes): pin mise installer for e2e

---------

Signed-off-by: Kirit93 <kthadaka@nvidia.com>
Signed-off-by: Kirit Thadaka <kthadaka@nvidia.com>
Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com>
2026-06-01 14:35:51 -07:00

1502 lines
53 KiB
Rust

// 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<GatewayMessage>,
/// 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<Result<tokio::io::DuplexStream, Status>>;
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<HashMap<String, LiveSession>>,
/// `channel_id` -> oneshot sender for the reverse CONNECT stream.
pending_relays: Mutex<HashMap<String, PendingRelay>>,
}
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<GatewayMessage>,
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<mpsc::Sender<GatewayMessage>, 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<mpsc::Sender<GatewayMessage>> {
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<String> {
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<Result<tokio::io::DuplexStream, Status>>,
),
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<Result<tokio::io::DuplexStream, Status>>,
),
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<tokio::io::DuplexStream, Status> {
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<GatewayMessage>) {
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<ServerState>, 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<crate::persistence::Store>,
sandbox_id: &str,
) -> Result<(), Status> {
let sandbox = store
.get_message::<Sandbox>(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<tonic::Streaming<RelayFrame>>,
) -> Result<
Response<
Pin<Box<dyn tokio_stream::Stream<Item = Result<RelayFrame, Status>> + Send + 'static>>,
>,
Status,
> {
let principal = request.extensions().get::<Principal>().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::<Result<RelayFrame, Status>>(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<dyn tokio_stream::Stream<Item = Result<RelayFrame, Status>> + Send + 'static>,
> = Box::pin(stream);
Ok(Response::new(stream))
}
// ---------------------------------------------------------------------------
// ConnectSupervisor gRPC handler
// ---------------------------------------------------------------------------
pub async fn handle_connect_supervisor(
state: &Arc<ServerState>,
request: Request<tonic::Streaming<SupervisorMessage>>,
) -> Result<
Response<
Pin<Box<dyn tokio_stream::Stream<Item = Result<GatewayMessage, Status>> + Send + 'static>>,
>,
Status,
> {
let principal = request.extensions().get::<Principal>().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::<GatewayMessage>(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<dyn tokio_stream::Stream<Item = Result<GatewayMessage, Status>> + Send + 'static>,
> = Box::pin(tokio_stream::StreamExt::map(stream, Ok));
Ok(Response::new(stream))
}
async fn run_session_loop(
state: &Arc<ServerState>,
sandbox_id: &str,
session_id: &str,
tx: &mpsc::Sender<GatewayMessage>,
inbound: &mut tonic::Streaming<SupervisorMessage>,
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<ServerState>,
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<Store> {
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(&registry);
// 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::<GatewayMessage>(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::<GatewayMessage>(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::<GatewayMessage>(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::<GatewayMessage>(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::<GatewayMessage>(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::<GatewayMessage>(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::<GatewayMessage>(1);
let (tx_new, _rx_new) = mpsc::channel::<GatewayMessage>(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::<GatewayMessage>(4);
let (tx_new, mut rx_new) = mpsc::channel::<GatewayMessage>(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", &registry.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::<Result<tokio::io::DuplexStream, Status>>();
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::<Result<tokio::io::DuplexStream, Status>>();
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")
);
}
}