Skip to content

Commit 7a850ae

Browse files
committed
feat(server): cap in-flight relay channels per sandbox and globally
Adds two `ResourceExhausted`-returning guards on `open_relay` to bound the `pending_relays` map against runaway or abusive callers: - `MAX_PENDING_RELAYS = 256` — upper bound across all sandboxes. Caps the memory a caller can pin by calling `open_relay` in a loop while no supervisor ever claims (or the supervisor is hung). - `MAX_PENDING_RELAYS_PER_SANDBOX = 32` — per-sandbox ceiling so one noisy tenant can't consume the entire global budget. Sits above the existing SSH-tunnel per-sandbox cap (20) so tunnel-specific limits still fire first for that caller. Both checks and the `pending_relays` insert happen under a single lock hold so concurrent callers can't each observe "under the cap" and both insert past it. Adds a `sandbox_id` field on `PendingRelay` so the per-sandbox count is a single filter over the map without extra indexes. Tests: - Two unit tests in `supervisor_session.rs` — assert the global cap and the per-sandbox cap both return `ResourceExhausted` with the right message, and a cap-hit on one sandbox doesn't leak onto others. - One integration test in `supervisor_relay_integration.rs` — bursts 64 concurrent `open_relay` calls at a single sandbox and asserts exactly 32 succeed, exactly 32 are rejected with the per-sandbox message, and a different sandbox still accepts new relays. Reaper behaviour is unchanged; the cap makes the map bounded, so the existing `HashMap::retain` pass stays cheap under any load.
1 parent 482980b commit 7a850ae

2 files changed

Lines changed: 165 additions & 1 deletion

File tree

crates/openshell-server/src/supervisor_session.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10);
2525
const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
2626
/// Maximum backoff between session-availability polls in `wait_for_session`.
2727
const SESSION_WAIT_MAX_BACKOFF: Duration = Duration::from_secs(2);
28+
/// Upper bound on unclaimed relay channels across all sandboxes. Caps the
29+
/// memory a misbehaving caller can pin by calling `open_relay` repeatedly
30+
/// while the supervisor never claims (or isn't responding). Sized generously
31+
/// so normal bursts pass through; exceeding it returns `ResourceExhausted`.
32+
const MAX_PENDING_RELAYS: usize = 256;
33+
/// Upper bound on concurrent unclaimed relay channels for a single sandbox.
34+
/// Enforces the same shape per sandbox so one misbehaving sandbox can't
35+
/// consume the entire global budget. Sits above the SSH-tunnel per-sandbox
36+
/// cap (20) so tunnel-specific limits still fire first for that caller.
37+
const MAX_PENDING_RELAYS_PER_SANDBOX: usize = 32;
2838

2939
// ---------------------------------------------------------------------------
3040
// Session registry
@@ -56,6 +66,7 @@ pub struct SupervisorSessionRegistry {
5666

5767
struct PendingRelay {
5868
sender: RelayStreamSender,
69+
sandbox_id: String,
5970
created_at: Instant,
6071
}
6172

@@ -186,13 +197,31 @@ impl SupervisorSessionRegistry {
186197
let channel_id = Uuid::new_v4().to_string();
187198

188199
// Register the pending relay before sending RelayOpen to avoid a race.
200+
// Both caps are checked and the insert happens under a single lock hold
201+
// so two concurrent calls can't both observe "under the cap" and then
202+
// both insert past it.
189203
let (relay_tx, relay_rx) = oneshot::channel();
190204
{
191205
let mut pending = self.pending_relays.lock().unwrap();
206+
if pending.len() >= MAX_PENDING_RELAYS {
207+
return Err(Status::resource_exhausted(format!(
208+
"gateway relay capacity reached ({MAX_PENDING_RELAYS} in flight)"
209+
)));
210+
}
211+
let per_sandbox = pending
212+
.values()
213+
.filter(|p| p.sandbox_id == sandbox_id)
214+
.count();
215+
if per_sandbox >= MAX_PENDING_RELAYS_PER_SANDBOX {
216+
return Err(Status::resource_exhausted(format!(
217+
"per-sandbox relay limit reached ({MAX_PENDING_RELAYS_PER_SANDBOX} in flight for {sandbox_id})"
218+
)));
219+
}
192220
pending.insert(
193221
channel_id.clone(),
194222
PendingRelay {
195223
sender: relay_tx,
224+
sandbox_id: sandbox_id.to_string(),
196225
created_at: Instant::now(),
197226
},
198227
);
@@ -731,6 +760,76 @@ mod tests {
731760
assert!(registry.pending_relays.lock().unwrap().is_empty());
732761
}
733762

763+
#[tokio::test]
764+
async fn open_relay_rejects_when_global_cap_reached() {
765+
let registry = SupervisorSessionRegistry::new();
766+
let (tx, _rx) = mpsc::channel::<GatewayMessage>(8);
767+
registry.register("sbx-a".to_string(), "s-a".to_string(), tx.clone());
768+
registry.register("sbx-b".to_string(), "s-b".to_string(), tx);
769+
770+
// Pre-seed pending_relays to exactly the global cap, split across two
771+
// sandboxes so neither hits the per-sandbox cap first.
772+
{
773+
let mut pending = registry.pending_relays.lock().unwrap();
774+
for i in 0..MAX_PENDING_RELAYS {
775+
let (oneshot_tx, _) = oneshot::channel();
776+
let sandbox_id = if i % 2 == 0 { "sbx-a" } else { "sbx-b" };
777+
pending.insert(
778+
format!("channel-{i}"),
779+
PendingRelay {
780+
sender: oneshot_tx,
781+
sandbox_id: sandbox_id.to_string(),
782+
created_at: Instant::now(),
783+
},
784+
);
785+
}
786+
}
787+
788+
let err = registry
789+
.open_relay("sbx-a", Duration::from_millis(50))
790+
.await
791+
.expect_err("open_relay should reject once global cap is reached");
792+
assert_eq!(err.code(), tonic::Code::ResourceExhausted);
793+
assert!(err.message().contains("gateway relay capacity"));
794+
}
795+
796+
#[tokio::test]
797+
async fn open_relay_rejects_when_per_sandbox_cap_reached() {
798+
let registry = SupervisorSessionRegistry::new();
799+
let (tx, _rx) = mpsc::channel::<GatewayMessage>(8);
800+
registry.register("sbx".to_string(), "s".to_string(), tx);
801+
802+
{
803+
let mut pending = registry.pending_relays.lock().unwrap();
804+
for i in 0..MAX_PENDING_RELAYS_PER_SANDBOX {
805+
let (oneshot_tx, _) = oneshot::channel();
806+
pending.insert(
807+
format!("channel-{i}"),
808+
PendingRelay {
809+
sender: oneshot_tx,
810+
sandbox_id: "sbx".to_string(),
811+
created_at: Instant::now(),
812+
},
813+
);
814+
}
815+
}
816+
817+
let err = registry
818+
.open_relay("sbx", Duration::from_millis(50))
819+
.await
820+
.expect_err("open_relay should reject when per-sandbox cap is reached");
821+
assert_eq!(err.code(), tonic::Code::ResourceExhausted);
822+
assert!(err.message().contains("per-sandbox relay limit"));
823+
824+
// A different sandbox still has headroom.
825+
let (tx2, _rx2) = mpsc::channel::<GatewayMessage>(8);
826+
registry.register("sbx-other".to_string(), "s-other".to_string(), tx2);
827+
registry
828+
.open_relay("sbx-other", Duration::from_millis(50))
829+
.await
830+
.expect("different sandbox should still accept new relays");
831+
}
832+
734833
#[tokio::test]
735834
async fn open_relay_uses_newest_session_after_supersede() {
736835
let registry = SupervisorSessionRegistry::new();
@@ -785,6 +884,7 @@ mod tests {
785884
"ch-1".to_string(),
786885
PendingRelay {
787886
sender: relay_tx,
887+
sandbox_id: "sbx-test".to_string(),
788888
created_at: Instant::now(),
789889
},
790890
);
@@ -802,6 +902,7 @@ mod tests {
802902
"ch-old".to_string(),
803903
PendingRelay {
804904
sender: relay_tx,
905+
sandbox_id: "sbx-test".to_string(),
805906
created_at: Instant::now() - Duration::from_secs(60),
806907
},
807908
);
@@ -829,6 +930,7 @@ mod tests {
829930
"ch-1".to_string(),
830931
PendingRelay {
831932
sender: relay_tx,
933+
sandbox_id: "sbx-test".to_string(),
832934
created_at: Instant::now(),
833935
},
834936
);
@@ -847,6 +949,7 @@ mod tests {
847949
"ch-io".to_string(),
848950
PendingRelay {
849951
sender: relay_tx,
952+
sandbox_id: "sbx-test".to_string(),
850953
created_at: Instant::now(),
851954
},
852955
);
@@ -877,6 +980,7 @@ mod tests {
877980
"ch-old".to_string(),
878981
PendingRelay {
879982
sender: relay_tx,
983+
sandbox_id: "sbx-test".to_string(),
880984
created_at: Instant::now() - Duration::from_secs(60),
881985
},
882986
);
@@ -899,6 +1003,7 @@ mod tests {
8991003
"ch-fresh".to_string(),
9001004
PendingRelay {
9011005
sender: relay_tx,
1006+
sandbox_id: "sbx-test".to_string(),
9021007
created_at: Instant::now(),
9031008
},
9041009
);

crates/openshell-server/tests/supervisor_relay_integration.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,15 @@ fn register_session(
306306
registry: &SupervisorSessionRegistry,
307307
sandbox_id: &str,
308308
) -> mpsc::Receiver<GatewayMessage> {
309-
let (tx, rx) = mpsc::channel(8);
309+
register_session_with_capacity(registry, sandbox_id, 8)
310+
}
311+
312+
fn register_session_with_capacity(
313+
registry: &SupervisorSessionRegistry,
314+
sandbox_id: &str,
315+
capacity: usize,
316+
) -> mpsc::Receiver<GatewayMessage> {
317+
let (tx, rx) = mpsc::channel(capacity);
310318
registry.register(sandbox_id.to_string(), "sess-1".to_string(), tx);
311319
rx
312320
}
@@ -505,3 +513,54 @@ async fn concurrent_relays_multiplex_independently() {
505513
assert_eq!(&buf_a, b"stream-A");
506514
assert_eq!(&buf_b, b"stream-B");
507515
}
516+
517+
/// Bursts more `open_relay` calls than the per-sandbox cap allows in parallel
518+
/// and asserts the registry enforces the ceiling cleanly. A well-behaved
519+
/// caller inside the cap still succeeds; overflow calls return `ResourceExhausted`
520+
/// rather than racing the pending map into an inconsistent state.
521+
#[tokio::test]
522+
async fn open_relay_enforces_per_sandbox_cap_under_concurrent_burst() {
523+
let registry = Arc::new(SupervisorSessionRegistry::new());
524+
let _channel = spawn_gateway(Arc::clone(&registry)).await;
525+
// Oversized mpsc so the session doesn't backpressure the burst — the cap,
526+
// not the channel, is what we're testing.
527+
let _session_rx = register_session_with_capacity(&registry, "sbx", 256);
528+
529+
// Fire 64 concurrent opens. Per-sandbox cap is 32, global cap is 256,
530+
// so exactly 32 should succeed and 32 should be rejected with
531+
// `ResourceExhausted` carrying the per-sandbox message.
532+
let mut handles = Vec::with_capacity(64);
533+
for _ in 0..64 {
534+
let r = Arc::clone(&registry);
535+
handles.push(tokio::spawn(async move {
536+
r.open_relay("sbx", Duration::from_secs(1)).await
537+
}));
538+
}
539+
540+
let mut ok = 0usize;
541+
let mut exhausted = 0usize;
542+
for h in handles {
543+
match h.await.expect("task joined") {
544+
Ok(_pair) => ok += 1,
545+
Err(status) if status.code() == tonic::Code::ResourceExhausted => {
546+
assert!(
547+
status.message().contains("per-sandbox relay limit"),
548+
"expected per-sandbox error message, got: {}",
549+
status.message()
550+
);
551+
exhausted += 1;
552+
}
553+
Err(other) => panic!("unexpected open_relay error: {other:?}"),
554+
}
555+
}
556+
assert_eq!(ok, 32, "exactly per-sandbox cap should succeed");
557+
assert_eq!(exhausted, 32, "overflow should be rejected, not dropped");
558+
559+
// A different sandbox still has headroom — the per-sandbox cap doesn't
560+
// leak onto unrelated tenants.
561+
let _other_rx = register_session_with_capacity(&registry, "sbx-other", 8);
562+
registry
563+
.open_relay("sbx-other", Duration::from_secs(1))
564+
.await
565+
.expect("other sandbox should not be affected by sbx cap");
566+
}

0 commit comments

Comments
 (0)