Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,19 @@ synchronously. Sandbox row removal remains bound to the stable ID and resource
version. Settings retain their existing best-effort name-based cleanup; SSH
sessions, indexes, and watch/log buses are cleaned after confirmed removal.

When a sandbox is instead discovered gone out-of-band — a watcher deletion
event, or the periodic prune sweep finding no matching driver resource, with
no explicit `DeleteSandbox` request involved at all — the gateway also
releases driver-owned resources (for example Podman's per-sandbox secrets and
workspace volume) by calling the driver's idempotent `DeleteSandbox`, not just
gateway state. Both paths skip that call when a request-side lifecycle
operation already holds the sandbox's gate, since that operation already owns
driver-side cleanup. The watch path defers the call itself to a background
task after a non-blocking gate check, so a slow driver call cannot stall the
sequential watch loop; the prune sweep calls the driver inline, since it
already makes a blocking `GetSandbox` call per sandbox as part of its normal
operation.

The request acquires both locks before starting owned work, so cancellation
while queued does not leave a delete armed. After that commitment point, the
owned task prevents cancellation from stranding a mutation. A gateway restart
Expand Down
246 changes: 245 additions & 1 deletion crates/openshell-server/src/compute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3249,6 +3249,13 @@ impl ComputeRuntime {
.await
.map_err(|e| e.to_string())?;
if let Some(sandbox) = sandbox.as_ref() {
// The watcher told us this sandbox's compute resource is gone, so
// no request-side DeleteSandbox call is coming — release
// driver-owned resources in the background. Watch events are
// processed sequentially, so this must not block on the driver
// call itself, only on the (instant, non-blocking) decision to
// make it.
self.spawn_driver_sandbox_cleanup(sandbox.object_id(), sandbox.object_name());
self.cleanup_sandbox_owned_records(sandbox).await?;
}

Expand Down Expand Up @@ -3300,6 +3307,79 @@ impl ComputeRuntime {
Ok(())
}

/// Best-effort driver-side cleanup for a sandbox discovered gone
/// out-of-band — a watch deletion event, or the periodic prune sweep
/// finding no matching driver resource — rather than through an
/// explicit `DeleteSandbox` request.
///
/// Skips the driver call entirely if a request-side lifecycle operation
/// (e.g. an in-flight explicit delete) already holds this sandbox's
/// lifecycle gate: that operation already owns driver-side cleanup for
/// it, and calling `DeleteSandbox` again here would race its own
/// in-flight call.
///
/// The gate check is synchronous, but the actual `DeleteSandbox` RPC is
/// always deferred to a background task, never awaited inline: both
/// call sites run while holding a broader lock (the watch loop's
/// sequential event processing; the prune sweep's gateway-wide
/// `sync_lock`), and a slow or stuck driver call must never block that
/// wider scope. The gate itself is held for the background call's
/// duration, so this still can't race a concurrent request-side
/// operation — only the potentially-slow RPC is backgrounded.
fn spawn_driver_sandbox_cleanup(&self, sandbox_id: &str, sandbox_name: &str) {
let gate = self.lifecycle_gates.gate_for(sandbox_id);
let Ok(guard) = gate.try_lock_owned() else {
debug!(
sandbox_id,
sandbox_name,
"Skipping driver cleanup while a lifecycle operation is already in flight for this sandbox"
);
return;
};

let runtime = self.clone();
let sandbox_id = sandbox_id.to_string();
let sandbox_name = sandbox_name.to_string();
tokio::spawn(async move {
let _guard = guard;
runtime
.call_driver_delete_sandbox(&sandbox_id, &sandbox_name)
.await;
});
}

/// `DeleteSandbox` is idempotent: drivers must reclaim owned
/// secrets/volumes/etc. even when the underlying compute resource is
/// already gone. Failures here are logged, not propagated — callers
/// already consider this sandbox gone, so a driver hiccup must not
/// block store cleanup.
async fn call_driver_delete_sandbox(&self, sandbox_id: &str, sandbox_name: &str) {
let result = self
.driver
.call("driver.delete_sandbox", Some(sandbox_id), |driver| {
let sandbox_id = sandbox_id.to_string();
let sandbox_name = sandbox_name.to_string();
async move {
driver
.delete_sandbox(Request::new(DeleteSandboxRequest {
sandbox_id,
sandbox_name,
}))
.await
}
})
.await;

if let Err(status) = result {
warn!(
sandbox_id,
sandbox_name,
error = %status,
"Failed to release driver-owned resources while cleaning up a sandbox discovered gone out-of-band"
);
}
}

async fn cleanup_sandbox_ssh_sessions(
&self,
sandbox_id: &str,
Expand Down Expand Up @@ -3553,6 +3633,14 @@ impl ComputeRuntime {
age_secs = age_ms / 1000,
"Removing sandbox from store after it disappeared from the compute driver snapshot"
);
// The driver's own snapshot never reported this sandbox, so no
// request-side DeleteSandbox call is coming for it either — release
// driver-owned resources in the background. This function holds
// `sync_lock` (the gateway-wide state guard) through the rest of its
// body, so the driver call must not be awaited here: doing so would
// block every other sandbox operation gateway-wide on a single,
// potentially slow or stuck driver RPC.
self.spawn_driver_sandbox_cleanup(&sandbox_id, &sandbox_name);
self.apply_deleted_if_version_locked(&sandbox, expected_resource_version)
.await
}
Expand Down Expand Up @@ -4984,6 +5072,7 @@ mod tests {
delete_release: Semaphore,
delete_blocked: AtomicBool,
delete_calls: AtomicUsize,
delete_requests: TestMutex<Vec<(String, String)>>,
delete_outcome: TestMutex<ControlledDeleteOutcome>,
stop_started: Notify,
stop_finished: Notify,
Expand Down Expand Up @@ -5016,6 +5105,7 @@ mod tests {
delete_release: Semaphore::new(0),
delete_blocked: AtomicBool::new(false),
delete_calls: AtomicUsize::new(0),
delete_requests: TestMutex::new(Vec::new()),
delete_outcome: TestMutex::new(ControlledDeleteOutcome::Ok(true)),
stop_started: Notify::new(),
stop_finished: Notify::new(),
Expand Down Expand Up @@ -5099,6 +5189,13 @@ mod tests {
self.delete_calls.load(Ordering::SeqCst)
}

fn delete_requests(&self) -> Vec<(String, String)> {
self.delete_requests
.lock()
.expect("delete requests lock poisoned")
.clone()
}

fn stop_calls(&self) -> usize {
self.stop_calls.load(Ordering::SeqCst)
}
Expand Down Expand Up @@ -5286,8 +5383,13 @@ mod tests {

async fn delete_sandbox(
&self,
_request: Request<DeleteSandboxRequest>,
request: Request<DeleteSandboxRequest>,
) -> Result<tonic::Response<DeleteSandboxResponse>, Status> {
let request = request.into_inner();
self.delete_requests
.lock()
.expect("delete requests lock poisoned")
.push((request.sandbox_id, request.sandbox_name));
self.delete_calls.fetch_add(1, Ordering::SeqCst);
self.delete_started.notify_one();
if self.delete_blocked.load(Ordering::SeqCst) {
Expand Down Expand Up @@ -7974,6 +8076,148 @@ mod tests {
));
}

#[tokio::test]
async fn apply_deleted_releases_driver_resources_for_out_of_band_removal() {
// Regression test for #2352: a container removed without going
// through OpenShell's own DeleteSandbox (e.g. `podman rm -f`) must
// still release driver-owned secrets/volumes, not just the store
// record. This never calls `runtime.delete_sandbox`, matching the
// out-of-band removal the issue reports.
let driver = ControlledDriver::new();
let runtime = test_runtime(driver.clone()).await;
let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready);
runtime.store.put_message(&sandbox).await.unwrap();
let session = seed_sandbox_owned_records(&runtime, &sandbox).await;

assert_eq!(driver.delete_calls(), 0);

runtime.apply_deleted("sb-1").await.unwrap();

// The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`)
// so it can't block the watch loop; wait for it to actually land
// before asserting on it.
tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified())
.await
.expect("background driver cleanup did not run");
assert_eq!(
driver.delete_requests(),
vec![("sb-1".to_string(), "sandbox-a".to_string())]
);
assert_sandbox_owned_records(&runtime, &sandbox, &session, false).await;
assert!(
runtime
.store
.get_message::<Sandbox>("sb-1")
.await
.unwrap()
.is_none()
);
}

#[tokio::test]
async fn apply_deleted_removes_store_record_even_when_driver_cleanup_fails() {
// The driver has already told us (via the watch/prune path that led
// here) that this sandbox is gone. A failure releasing its
// secrets/volumes must be logged, not block store cleanup — retrying
// forever on a driver hiccup would leave the store permanently out
// of sync with reality.
let driver = ControlledDriver::new();
driver.set_delete_outcome(ControlledDeleteOutcome::Error("podman unreachable"));
let runtime = test_runtime(driver.clone()).await;
let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready);
runtime.store.put_message(&sandbox).await.unwrap();

runtime.apply_deleted("sb-1").await.unwrap();

// Store cleanup happens synchronously in `apply_deleted_locked`, so
// this is already true even though the driver call is backgrounded.
assert!(
runtime
.store
.get_message::<Sandbox>("sb-1")
.await
.unwrap()
.is_none()
);

tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified())
.await
.expect("background driver cleanup did not run");
assert_eq!(driver.delete_calls(), 1);
}

#[tokio::test]
async fn prune_missing_sandbox_releases_driver_resources() {
// Regression test for #2352's second reproduction: a sandbox whose
// container never survived to exist at all (e.g. the gateway was
// killed mid-create) is discovered missing by the periodic sweep,
// not the watcher. That path must also release driver resources.
let driver = ControlledDriver::new();
driver.set_get_outcome(ControlledGetOutcome::Missing);
let runtime = test_runtime(driver.clone()).await;
let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning);
runtime.store.put_message(&sandbox).await.unwrap();

runtime
.reconcile_store_with_backend(Duration::ZERO)
.await
.unwrap();

assert!(
runtime
.store
.get_message::<Sandbox>("sb-1")
.await
.unwrap()
.is_none()
);

// The driver call is backgrounded (see `spawn_driver_sandbox_cleanup`)
// so the prune sweep never awaits it while holding the gateway-wide
// sync_lock; wait for it to actually land before asserting on it.
tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified())
.await
.expect("background driver cleanup did not run");
assert_eq!(
driver.delete_requests(),
vec![("sb-1".to_string(), "sandbox-a".to_string())]
);
}

#[tokio::test]
async fn prune_sweep_does_not_block_on_a_stuck_driver_delete_call() {
// Regression test: the prune sweep's driver cleanup must not be
// awaited while holding `sync_lock` (the gateway-wide state guard).
// Block the driver's delete call indefinitely and confirm the sweep
// itself still completes promptly and removes the store record.
let driver = ControlledDriver::new();
driver.block_delete();
driver.set_get_outcome(ControlledGetOutcome::Missing);
let runtime = test_runtime(driver.clone()).await;
let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning);
runtime.store.put_message(&sandbox).await.unwrap();

tokio::time::timeout(
Duration::from_secs(1),
runtime.reconcile_store_with_backend(Duration::ZERO),
)
.await
.expect("prune sweep blocked on the stuck driver delete call")
.unwrap();

assert!(
runtime
.store
.get_message::<Sandbox>("sb-1")
.await
.unwrap()
.is_none()
);
tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified())
.await
.expect("background driver cleanup did not run");
}

#[tokio::test]
async fn accepted_delete_cleans_local_state_after_another_replica_removes_row() {
let driver = ControlledDriver::new();
Expand Down
Loading