Skip to content

Commit 9887858

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(cri): tear down the sandbox VM if RunPodSandbox is cancelled before storing it (#116)
RunPodSandbox boots the sandbox VM (acquire_vm_with_box_id) and allocates a network IP BEFORE it registers the sandbox in the store + vm_managers. The explicit error paths already clean up, but an async CANCELLATION — the kubelet's RunPodSandbox deadline drops the gRPC future — between the boot and add_sandbox left the booted shim microVM and its IP orphaned: VmManager has no Drop, and an unregistered sandbox is invisible to the reaper, so nothing ever reaped it. Insert the VM into vm_managers immediately after boot, then arm a CancelGuard whose Drop (on unwind) disconnects the network synchronously and spawns the async VM destroy on the still-running runtime. It is disarmed the instant the sandbox is stored — with no `.await` between add_sandbox and disarm, so a successfully-created sandbox can never be torn down by a late cancellation. Test (neuter-verified): test_cancel_guard_runs_cleanup_unless_disarmed — an armed guard runs its cleanup on drop, a disarmed one does not. Making disarm a no-op fails the "disarmed guard must not run its cleanup" assertion. Audit finding #21. Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent fd7b938 commit 9887858

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

src/cri/src/runtime_service/mod.rs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,38 @@ type AttachStreamSender = broadcast::Sender<a3s_box_core::exec::ExecEvent>;
7272
type AttachStreamMap = Arc<RwLock<HashMap<String, AttachStreamSender>>>;
7373
type WorkloadStdinSender = StreamingInput;
7474
type WorkloadStdinMap = Arc<RwLock<HashMap<String, WorkloadStdinSender>>>;
75+
76+
/// Runs a cleanup closure on drop unless [`disarm`](CancelGuard::disarm)ed.
77+
///
78+
/// `RunPodSandbox` boots the sandbox VM and allocates a network IP BEFORE it
79+
/// registers the sandbox in the store. If the request future is dropped in
80+
/// between — a kubelet RunPodSandbox deadline cancels the gRPC call — the booted
81+
/// shim microVM and its IP would leak: `VmManager` has no `Drop`, and an
82+
/// unregistered sandbox is invisible to the reaper. This guard tears them down
83+
/// on unwind and is disarmed the instant the sandbox is stored.
84+
struct CancelGuard<F: FnOnce()> {
85+
cleanup: Option<F>,
86+
}
87+
88+
impl<F: FnOnce()> CancelGuard<F> {
89+
fn new(cleanup: F) -> Self {
90+
Self {
91+
cleanup: Some(cleanup),
92+
}
93+
}
94+
95+
fn disarm(&mut self) {
96+
self.cleanup = None;
97+
}
98+
}
99+
100+
impl<F: FnOnce()> Drop for CancelGuard<F> {
101+
fn drop(&mut self) {
102+
if let Some(cleanup) = self.cleanup.take() {
103+
cleanup();
104+
}
105+
}
106+
}
75107
type WorkloadStopSender = oneshot::Sender<()>;
76108
type WorkloadStopMap = Arc<RwLock<HashMap<String, WorkloadStopSender>>>;
77109
/// Per-container handle for CRI ReopenContainerLog. `request` wakes the exit
@@ -566,6 +598,41 @@ impl RuntimeService for BoxRuntimeService {
566598
}
567599
};
568600

601+
// Track the VM immediately (it used to be inserted only after
602+
// add_sandbox) so the cancel guard below can find and destroy it.
603+
self.vm_managers
604+
.write()
605+
.await
606+
.insert(sandbox_id.clone(), vm);
607+
608+
// Arm the cancel guard: from here until the sandbox is stored, a dropped
609+
// request future (kubelet deadline) must not leak the booted VM + IP.
610+
let cancel_network_name = network_allocation.as_ref().map(|a| a.network_name.clone());
611+
let mut cancel_guard = {
612+
let vm_managers = self.vm_managers.clone();
613+
let network_store = self.network_store.clone();
614+
let sid = sandbox_id.clone();
615+
CancelGuard::new(move || {
616+
// Network disconnect is synchronous — do it directly in Drop.
617+
if let Some(network_name) = cancel_network_name {
618+
let _ = disconnect_sandbox_from_network_store(
619+
network_store.as_ref(),
620+
&network_name,
621+
&sid,
622+
);
623+
}
624+
// VM destroy is async — spawn it on the still-running runtime (the
625+
// request future is being dropped, not the server). Best-effort.
626+
if let Ok(handle) = tokio::runtime::Handle::try_current() {
627+
handle.spawn(async move {
628+
if let Some(mut vm) = vm_managers.write().await.remove(&sid) {
629+
let _ = vm.destroy().await;
630+
}
631+
});
632+
}
633+
})
634+
};
635+
569636
// For a default (TSI) pod that publishes ports but has no
570637
// allocated/annotated IP, report the loopback as the pod IP: TSI binds
571638
// 0.0.0.0:<hostPort> and forwards to the guest, so the pod's published
@@ -609,10 +676,11 @@ impl RuntimeService for BoxRuntimeService {
609676
};
610677

611678
self.store.add_sandbox(sandbox).await;
612-
self.vm_managers
613-
.write()
614-
.await
615-
.insert(sandbox_id.clone(), vm);
679+
// The sandbox is registered (the VM was inserted above); it is now owned
680+
// by the store + vm_managers, so stop guarding it. No `.await` runs
681+
// between add_sandbox and here, so a successfully-created sandbox can
682+
// never be torn down by a late cancellation.
683+
cancel_guard.disarm();
616684

617685
Ok(Response::new(RunPodSandboxResponse {
618686
pod_sandbox_id: sandbox_id,

src/cri/src/runtime_service/tests.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,41 @@ fn test_kept_capabilities() {
4444
assert!(kept_capabilities(Some(&cap(&["ALL"], &[]))).is_none());
4545
}
4646

47+
#[test]
48+
fn test_cancel_guard_runs_cleanup_unless_disarmed() {
49+
use std::sync::atomic::{AtomicUsize, Ordering};
50+
51+
let counter = Arc::new(AtomicUsize::new(0));
52+
53+
// Armed: a RunPodSandbox future dropped before the sandbox is stored must run
54+
// the teardown (destroy the orphaned VM + disconnect its network).
55+
{
56+
let c = counter.clone();
57+
let _guard = CancelGuard::new(move || {
58+
c.fetch_add(1, Ordering::SeqCst);
59+
});
60+
}
61+
assert_eq!(
62+
counter.load(Ordering::SeqCst),
63+
1,
64+
"an armed guard must run its cleanup on drop"
65+
);
66+
67+
// Disarmed: a sandbox that registered successfully must NOT be torn down.
68+
{
69+
let c = counter.clone();
70+
let mut guard = CancelGuard::new(move || {
71+
c.fetch_add(1, Ordering::SeqCst);
72+
});
73+
guard.disarm();
74+
}
75+
assert_eq!(
76+
counter.load(Ordering::SeqCst),
77+
1,
78+
"a disarmed guard must not run its cleanup"
79+
);
80+
}
81+
4782
/// Create a BoxRuntimeService for testing.
4883
/// Uses NoopStateStore (no disk I/O) and a dummy StreamingHandle.
4984
fn make_test_service() -> BoxRuntimeService {

0 commit comments

Comments
 (0)