mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-16 05:20:22 +08:00
* 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>
1502 lines
53 KiB
Rust
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(®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::<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", ®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::<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")
|
|
);
|
|
}
|
|
}
|