fix(controller): bound netns allocator to provisioned namespaces - #282
Conversation
7a6361c to
07b0c76
Compare
07b0c76 to
632b71d
Compare
|
Per the contributing guidelines, I ran the local gate on this branch:
The failing test is pre-existing and unrelated to this PR: it panics with Commits are signed off per the DCO requirement. |
|
Note on the force-push: the branch history was rewritten after the PR was opened, to align with the contributing guidelines:
|
WaylandYang
left a comment
There was a problem hiding this comment.
The allocator still has a check-then-act race. live_vms is locked only while pick_netns_offset scans it; the lock is released before Firecracker starts and before the VM is inserted. Concurrent sandbox/workspace spawns can therefore both receive the same provisioned range. This needs an atomic reservation (preferably an RAII lease that rolls back on spawn failure), shared by both call sites.
Two related issues should be addressed in the same allocator change: MAX_OFFSET = 256 creates false exhaustion for larger valid pools instead of deriving the bound from provisioned namespaces/configuration, and pool exhaustion currently becomes a generic 500 rather than a resource-exhaustion response. Please add allocator tests using an injectable provisioned-namespace view, including concurrent reservation and pools above 256.
…eeplethe#282) Replaces check-then-act pick_netns_offset with NetnsAllocator: - reserve(n) scans the provisioned pool and marks indices under one lock; returns a RAII lease released on drop (spawn failure) or committed after live_vms registration (spawn success) - shared by BOTH the sandbox (create_sandbox) and workspace (create/resume) spawn paths — the workspace path previously bypassed the old allocator's live_vms scan entirely - bound derived from the provisioned pool (netns dir scan or injectable probe), not a magic MAX_OFFSET=256 — pools > 256 work - exhaustion surfaces as 503 Service Unavailable, not a generic 500 - tests: concurrent reservations (Barrier-held leases), pools > 256, drop-releases, commit-reuses, disk-probe discovery, HTTP 503 path
72f21cd to
fe1d1fe
Compare
…eeplethe#282) Replaces check-then-act pick_netns_offset with NetnsAllocator: - reserve(n) scans the provisioned pool and marks indices under one lock; returns a RAII lease released on drop (spawn failure) or committed after live_vms registration (spawn success) - shared by BOTH the sandbox (create_sandbox) and workspace (create/resume) spawn paths — the workspace path previously bypassed the old allocator's live_vms scan entirely - bound derived from the provisioned pool (netns dir scan or injectable probe), not a magic MAX_OFFSET=256 — pools > 256 work - exhaustion surfaces as 503 Service Unavailable, not a generic 500 - tests: concurrent reservations (Barrier-held leases), pools > 256, drop-releases, commit-reuses, disk-probe discovery, HTTP 503 path
|
Thanks for the review — you're right on all three points, and the fix is now in. What changed (commit
Tests added: concurrent reservations (Barrier-held leases, so 32 spawns genuinely overlap), pool > 256 usable, drop-releases on failure, commit-reuses after registration, disk-probe discovery, and an HTTP-level 503 test. Tradeoffs / notes:
|
fe1d1fe to
02cc660
Compare
|
History rebuilt: the branch is now a single clean commit on top of current main (5e457c4) — no merged-upstream history in the diff. Same content as before: atomic NetnsAllocator with RAII reservation, provisioned-pool bound, 503 on exhaustion, all tests. Force-pushed; the old commits are gone from the branch. |
WaylandYang
left a comment
There was a problem hiding this comment.
Thanks for cleaning up the branch; the scoped diff is mergeable and CI is green. There is still a blocking ownership bug in the allocator.
NetnsReservation::commit() currently calls release(), while reserve() checks only the allocator's reserved set and the provisioned-namespace probe; it no longer consults live_vms. Therefore a successful spawn immediately makes its indices available again even though its VMs are still live.
I reproduced this directly against the current netns.rs:
- reserve(1) -> offset 0
- commit the reservation, representing a successfully registered live VM
- reserve(1) again
- the allocator returns offset 0 again instead of offset 1
The current reserves_disjoint_ranges test actually codifies this incorrect immediate reuse.
Please model committed ranges as active ownership until the associated VMs are deleted or suspended, and release the individual indices from all teardown paths (delete_sandbox, delete_workspace, suspend, and failure rollback). resume_workspace must also commit or transfer its reservation after registration.
There are two related workspace-path gaps: create/resume exhaustion still flows through server_error() as HTTP 500 rather than 503, and resume_workspace binds _netns_reservation without committing it.
Please add lifecycle tests covering:
- committed live ranges remain unavailable;
- failed spawns release pending ranges;
- deleting or suspending the live owner makes the range reusable;
- workspace resume retains ownership;
- workspace create/resume exhaustion returns 503 rather than 500.
Replace check-then-act pick_netns_offset with NetnsAllocator: - reserve(n) scans the provisioned pool and marks indices under one lock; returns a RAII lease released on drop (spawn failure) or committed after live_vms registration (spawn success) - committed ranges become ACTIVE and stay owned until the controller releases them via release_index() when the VM leaves live_vms (delete/suspend) — a still-live VM's range is never handed out again (review deeplethe#282 round 2) - shared by BOTH the sandbox (create_sandbox) and workspace (create/resume) spawn paths - bound derived from the provisioned pool (netns dir scan or injectable probe), not a magic MAX_OFFSET=256 — pools > 256 work - exhaustion surfaces as 503 Service Unavailable, not a generic 500 - tests: concurrent reservations (Barrier-held leases), pools > 256, commit-keeps-active + release_index frees, drop-releases, disk-probe discovery, HTTP 503 path
02cc660 to
108a5e3
Compare
|
Thanks for the deep review — confirmed the bug and fixed it (commit The What changed:
|
…pace 503 P0: resume_workspace bound _netns_reservation (underscore = unused) and never called commit(), dropping the reservation while the resumed VM was still live — re-opening the exact netns collision race deeplethe#282 claims to fix (review deeplethe#282 round 2). P1: workspace create/resume mapped netns exhaustion to 500 (server_error) instead of 503 (service_unavailable), inconsistent with create_sandbox. Introduced NetnsExhausted marker error, downcast to 503 in both workspace handlers. P2: DiskNetnsProbe now caches provisioned index set at construction (eliminates per-index stat under allocator locks). Teardown paths now kill firecracker (Vm::drop) before releasing the netns index, closing the window where a new spawn could enter forkd-child-N while the previous owner is still dying. release_netns_index logs a warning on unparseable netns strings. Tests: create_workspace_netns_exhaustion_returns_503, resume_workspace_netns_exhaustion_returns_503, concurrent_reservations_skip_committed_active.
…pace 503 P0: resume_workspace bound _netns_reservation (underscore = unused) and never called commit(), dropping the reservation while the resumed VM was still live — re-opening the exact netns collision race deeplethe#282 claims to fix (review deeplethe#282 round 2). P1: workspace create/resume mapped netns exhaustion to 500 (server_error) instead of 503 (service_unavailable), inconsistent with create_sandbox. Introduced NetnsExhausted marker error, downcast to 503 in both workspace handlers. P2: DiskNetnsProbe now caches provisioned index set at construction (eliminates per-index stat under allocator locks). Teardown paths now kill firecracker (Vm::drop) before releasing the netns index, closing the window where a new spawn could enter forkd-child-N while the previous owner is still dying. release_netns_index logs a warning on unparseable netns strings. Tests: create_workspace_netns_exhaustion_returns_503, resume_workspace_netns_exhaustion_returns_503, concurrent_reservations_skip_committed_active.
5c6683b to
6b64402
Compare
…l audit findings Rebases PR deeplethe#281's shared-tap lease on top of PR deeplethe#282's atomic netns allocator, resolving the cross-PR merge conflict. Both leases (netns reservation + shared-tap claim) now share the same lifecycle pattern: claimed inside spawn_blocking, committed AFTER live_vms.insert(). P1: tap lease commit moved out of spawn_blocking — previously committed inside the blocking task before live_vms registration, a cancelled handler between restore success and registration would permanently wedge the tap (DoS until restart). Now returned uncommitted and committed by the async handler after registration, mirroring deeplethe#282's netns commit placement. P1: teardown paths now drop the VM (kill firecracker) BEFORE releasing the shared-tap lease and netns index, closing the window where a new spawn could grab forkd-tap0 or forkd-child-N while the previous owner is still dying. P2: shared_tap_owner uses parking_lot::Mutex (not std::sync::Mutex, eliminating poisoning risk). release_shared_tap_if_owner now checks the owner token matches the departing VM's sandbox ID (defense-in- depth, was unconditional clear). The token is the sandbox ID (was the snapshot tag, which didn't match any ID in release). P2: spawn_one_for_workspace returns both Option<NetnsReservation> and Option<SharedTapClaim> as a 4-tuple; callers commit both after live_vms.insert(). Tests: create_sandbox_rejects_second_shared_tap_owner_with_503, create_sandbox_per_netns_ignores_shared_tap_lease, shared_tap_lease_claim_release_and_drop_semantics.
P0:
|
| WaylandYang item | Status |
|---|---|
| R1: atomic RAII reservation | ✅ |
| R1: drop MAX_OFFSET=256 | ✅ |
| R1: exhaustion → 503 (sandbox) | ✅ |
| R1: exhaustion → 503 (workspace) | ✅ now fixed |
| R1: injectable-probe tests | ✅ |
| R2: commit no longer releases | ✅ |
| R2: release from all teardown paths | ✅ |
| R2: resume_workspace must commit | ✅ now fixed |
| R2: lifecycle tests |
…pace 503 P0: resume_workspace bound _netns_reservation (underscore = unused) and never called commit(), dropping the reservation while the resumed VM was still live — re-opening the exact netns collision race deeplethe#282 claims to fix (review deeplethe#282 round 2). P1: workspace create/resume mapped netns exhaustion to 500 (server_error) instead of 503 (service_unavailable), inconsistent with create_sandbox. Introduced NetnsExhausted marker error, downcast to 503 in both workspace handlers. P2: DiskNetnsProbe now caches provisioned index set at construction (eliminates per-index stat under allocator locks). Teardown paths now kill firecracker (Vm::drop) before releasing the netns index, closing the window where a new spawn could enter forkd-child-N while the previous owner is still dying. release_netns_index logs a warning on unparseable netns strings. Tests: create_workspace_netns_exhaustion_returns_503, resume_workspace_netns_exhaustion_returns_503, concurrent_reservations_skip_committed_active.
6b64402 to
f8c917a
Compare
…l audit findings Rebases PR deeplethe#281's shared-tap lease on top of PR deeplethe#282's atomic netns allocator, resolving the cross-PR merge conflict. Both leases (netns reservation + shared-tap claim) now share the same lifecycle pattern: claimed inside spawn_blocking, committed AFTER live_vms.insert(). P1: tap lease commit moved out of spawn_blocking — previously committed inside the blocking task before live_vms registration, a cancelled handler between restore success and registration would permanently wedge the tap (DoS until restart). Now returned uncommitted and committed by the async handler after registration, mirroring deeplethe#282's netns commit placement. P1: teardown paths now drop the VM (kill firecracker) BEFORE releasing the shared-tap lease and netns index, closing the window where a new spawn could grab forkd-tap0 or forkd-child-N while the previous owner is still dying. P2: shared_tap_owner uses parking_lot::Mutex (not std::sync::Mutex, eliminating poisoning risk). release_shared_tap_if_owner now checks the owner token matches the departing VM's sandbox ID (defense-in- depth, was unconditional clear). The token is the sandbox ID (was the snapshot tag, which didn't match any ID in release). P2: spawn_one_for_workspace returns both Option<NetnsReservation> and Option<SharedTapClaim> as a 4-tuple; callers commit both after live_vms.insert(). Tests: create_sandbox_rejects_second_shared_tap_owner_with_503, create_sandbox_per_netns_ignores_shared_tap_lease, shared_tap_lease_claim_release_and_drop_semantics.
…l audit findings Rebases PR deeplethe#281's shared-tap lease on top of PR deeplethe#282's atomic netns allocator, resolving the cross-PR merge conflict. Both leases (netns reservation + shared-tap claim) now share the same lifecycle pattern: claimed inside spawn_blocking, committed AFTER live_vms.insert(). P1: tap lease commit moved out of spawn_blocking — previously committed inside the blocking task before live_vms registration, a cancelled handler between restore success and registration would permanently wedge the tap (DoS until restart). Now returned uncommitted and committed by the async handler after registration, mirroring deeplethe#282's netns commit placement. P1: teardown paths now drop the VM (kill firecracker) BEFORE releasing the shared-tap lease and netns index, closing the window where a new spawn could grab forkd-tap0 or forkd-child-N while the previous owner is still dying. P2: shared_tap_owner uses parking_lot::Mutex (not std::sync::Mutex, eliminating poisoning risk). release_shared_tap_if_owner now checks the owner token matches the departing VM's sandbox ID (defense-in- depth, was unconditional clear). The token is the sandbox ID (was the snapshot tag, which didn't match any ID in release). P2: spawn_one_for_workspace returns both Option<NetnsReservation> and Option<SharedTapClaim> as a 4-tuple; callers commit both after live_vms.insert(). Tests: create_sandbox_rejects_second_shared_tap_owner_with_503, create_sandbox_per_netns_ignores_shared_tap_lease, shared_tap_lease_claim_release_and_drop_semantics.
WaylandYang
left a comment
There was a problem hiding this comment.
CI is green now, but the current head still has cancellation/resource-lifetime gaps in the allocator integration:
create_sandboxcallsnetns_alloc.reserve(req.n)in the async handler before startingspawn_blocking. If that request future is cancelled while the blocking restore continues, the handler drops the reservation and makes the same indices available to another request, while the detached blocking task is still restoring Firecracker into those namespaces. Move the reservation into the blocking task and return it uncommitted with the result, as the workspace helper already does.- BRANCH and workspace suspend remove a VM from
live_vmsand then await a blocking task while its indices remain in the allocator''s active set. Cancellation drops/kills the returned VM without reachingrelease_netns_index, permanently leaking the active index. The same leak occurs if DELETE removes the registry entry during the BRANCH take-out window: the post-task branch dropsvm_backbut does not release its allocator ownership.
Please make resource ownership survive handler cancellation/concurrent deletion—ideally by tying the active allocation lease to the VM/task rather than relying on every async return path—and add cancellation plus DELETE-during-BRANCH/SUSPEND regression coverage.
WaylandYang
left a comment
There was a problem hiding this comment.
The allocator also loses ownership across controller restarts. Registry::reconcile() retains a sandbox when its recorded PID exists, but a newly constructed NetnsAllocator starts with an empty active set and live_vms is empty. After a controller crash with Firecracker still alive, a new spawn can therefore reserve the orphan VM's forkd-child-N namespace. DELETE cannot cleanly release/kill that orphan through live_vms, and checking only whether /proc/<pid> exists can mistake PID reuse for the original Firecracker.
Please add startup recovery that either verifies and kills orphan VMMs before clearing registry entries, or reconstructs allocator ownership and manageable VM state from the registry. A restart regression test should leave a Firecracker/registry entry alive, restart controller state, and prove its netns cannot be reallocated.
…/suspend Review deeplethe#282 round 3 (WaylandYang CHANGES_REQUESTED): 1. Move netns_alloc.reserve() from the async handler into the spawn_blocking task in create_sandbox. The old code reserved in the async context, so if the request future was cancelled while the blocking restore ran, the handler dropped the reservation (making indices available to another request) while the restore still used them. By owning the reservation in the blocking task, cancellation drops it only after the restore completes or fails. 2. Add VmNetnsGuard struct that kills the VM and releases the netns index on drop. Used in branch_sandbox and suspend_workspace to ensure netns cleanup survives async-handler cancellation and concurrent DELETE during the take-out window. When the handler is cancelled, the blocking task's return value (containing the guard) is dropped, triggering cleanup automatically. 3. Fix branch_sandbox DELETE-during-BRANCH path: the old code called drop(vm_back) without release_netns_index, permanently leaking the active index. The guard's Drop now handles this. 4. Fix suspend_workspace cancellation: the old code captured netns and released after drop only in the normal path. Cancellation dropped the task result without running this code. The guard's Drop handles both paths. 5. Add 3 regression tests verifying the allocator invariants the guard relies on: committed-index-released-then-reusable, uncommitted-reservation-blocks-concurrent-reserve, netns-index-lifecycle-reserved-active-free.
… round 3 fixes Review deeplethe#281 round 3 (WaylandYang CHANGES_REQUESTED) + rebase onto deeplethe#282 r3: 1. Reject shared-TAP batches with n>1 (503): when per_child_netns=false, all children share a single host tap fd. The tap lease is owned by the first child's sandbox id; deleting that child releases the lease while sibling VMs remain live, causing EBUSY on the next spawn. The common case is n=1; n>1 shared-TAP spawns are rejected until per-child tap ownership is modeled (review deeplethe#281 r3). 2. Extend VmNetnsGuard to release the shared-tap lease on Drop: the guard now carries optional shared_tap_owner + tap_owner_id. On Drop (cancellation or DELETE during BRANCH/suspend), it clears the owner if it matches. This ensures the tap lease is always released when the VM is killed, surviving handler cancellation (review deeplethe#281 r3 + deeplethe#282 r3). 3. Rebase shared-tap lease (SharedTapClaim, try_claim_shared_tap, release_shared_tap_if_owner) on top of deeplethe#282 r3's cancellation-safety changes. The tap claim is now made inside spawn_blocking alongside the netns reservation, returned uncommitted with the 3-tuple (ForkResult, Option<NetnsReservation>, Option<SharedTapClaim>), and committed after live_vms.insert. Both NetnsExhausted and SharedTapBusy are mapped to 503. 4. branch_sandbox and suspend_workspace guards now pass shared_tap_owner and the sandbox id, so the guard's Drop releases both netns and tap on cancellation or DELETE during the take-out window.
Round 3 fixes — cancellation safety for netns reservation and BRANCH/suspendAll CHANGES_REQUESTED findings from the 2026-08-12 review are addressed in commit 243fb45. 1. Move
|
… round 3 fixes Review deeplethe#281 round 3 (WaylandYang CHANGES_REQUESTED) + rebase onto deeplethe#282 r3: 1. Reject shared-TAP batches with n>1 (503): when per_child_netns=false, all children share a single host tap fd. The tap lease is owned by the first child's sandbox id; deleting that child releases the lease while sibling VMs remain live, causing EBUSY on the next spawn. The common case is n=1; n>1 shared-TAP spawns are rejected until per-child tap ownership is modeled (review deeplethe#281 r3). 2. Extend VmNetnsGuard to release the shared-tap lease on Drop: the guard now carries optional shared_tap_owner + tap_owner_id. On Drop (cancellation or DELETE during BRANCH/suspend), it clears the owner if it matches. This ensures the tap lease is always released when the VM is killed, surviving handler cancellation (review deeplethe#281 r3 + deeplethe#282 r3). 3. Rebase shared-tap lease (SharedTapClaim, try_claim_shared_tap, release_shared_tap_if_owner) on top of deeplethe#282 r3's cancellation-safety changes. The tap claim is now made inside spawn_blocking alongside the netns reservation, returned uncommitted with the 3-tuple (ForkResult, Option<NetnsReservation>, Option<SharedTapClaim>), and committed after live_vms.insert. Both NetnsExhausted and SharedTapBusy are mapped to 503. 4. branch_sandbox and suspend_workspace guards now pass shared_tap_owner and the sandbox id, so the guard's Drop releases both netns and tap on cancellation or DELETE during the take-out window.
CI fixRemoved unused All round 3 CHANGES_REQUESTED findings remain addressed:
The COMMENTED controller-restart gap (R3b) is tracked separately in #299. CI is now green across all 5 checks. Requesting re-review. |
WaylandYang
left a comment
There was a problem hiding this comment.
The cancellation/allocator lifecycle is now substantially improved, but VmNetnsGuard::into_vm() introduces a stable memory leak. It takes the Vm and then calls mem::forget(self), which permanently leaks the guard's Arc on every successful BRANCH reinsertion. A long-running controller can accumulate one leaked Arc allocation/reference per BRANCH without bound.
Please allow the consumed guard to drop normally and make Drop conditional on whether self.vm is still Some. If the VM was taken by into_vm, Drop should only destroy the remaining bookkeeping fields; if the guard still owns the VM, it should kill the VM and release the active netns index. Add a transfer-path regression test (Arc strong count or a drop sentinel is sufficient).
Fix: VmNetnsGuard::into_vm() Arc leakThe old Fix:
Regression test: |
Replace check-then-act pick_netns_offset with NetnsAllocator: - reserve(n) scans the provisioned pool and marks indices under one lock; returns a RAII lease released on drop (spawn failure) or committed after live_vms registration (spawn success) - committed ranges become ACTIVE and stay owned until the controller releases them via release_index() when the VM leaves live_vms (delete/suspend) — a still-live VM's range is never handed out again (review deeplethe#282 round 2) - shared by BOTH the sandbox (create_sandbox) and workspace (create/resume) spawn paths - bound derived from the provisioned pool (netns dir scan or injectable probe), not a magic MAX_OFFSET=256 — pools > 256 work - exhaustion surfaces as 503 Service Unavailable, not a generic 500 - tests: concurrent reservations (Barrier-held leases), pools > 256, commit-keeps-active + release_index frees, drop-releases, disk-probe discovery, HTTP 503 path
…pace 503 P0: resume_workspace bound _netns_reservation (underscore = unused) and never called commit(), dropping the reservation while the resumed VM was still live — re-opening the exact netns collision race deeplethe#282 claims to fix (review deeplethe#282 round 2). P1: workspace create/resume mapped netns exhaustion to 500 (server_error) instead of 503 (service_unavailable), inconsistent with create_sandbox. Introduced NetnsExhausted marker error, downcast to 503 in both workspace handlers. P2: DiskNetnsProbe now caches provisioned index set at construction (eliminates per-index stat under allocator locks). Teardown paths now kill firecracker (Vm::drop) before releasing the netns index, closing the window where a new spawn could enter forkd-child-N while the previous owner is still dying. release_netns_index logs a warning on unparseable netns strings. Tests: create_workspace_netns_exhaustion_returns_503, resume_workspace_netns_exhaustion_returns_503, concurrent_reservations_skip_committed_active.
…/suspend Review deeplethe#282 round 3 (WaylandYang CHANGES_REQUESTED): 1. Move netns_alloc.reserve() from the async handler into the spawn_blocking task in create_sandbox. The old code reserved in the async context, so if the request future was cancelled while the blocking restore ran, the handler dropped the reservation (making indices available to another request) while the restore still used them. By owning the reservation in the blocking task, cancellation drops it only after the restore completes or fails. 2. Add VmNetnsGuard struct that kills the VM and releases the netns index on drop. Used in branch_sandbox and suspend_workspace to ensure netns cleanup survives async-handler cancellation and concurrent DELETE during the take-out window. When the handler is cancelled, the blocking task's return value (containing the guard) is dropped, triggering cleanup automatically. 3. Fix branch_sandbox DELETE-during-BRANCH path: the old code called drop(vm_back) without release_netns_index, permanently leaking the active index. The guard's Drop now handles this. 4. Fix suspend_workspace cancellation: the old code captured netns and released after drop only in the normal path. Cancellation dropped the task result without running this code. The guard's Drop handles both paths. 5. Add 3 regression tests verifying the allocator invariants the guard relies on: committed-index-released-then-reusable, uncommitted-reservation-blocks-concurrent-reserve, netns-index-lifecycle-reserved-active-free.
Signed-off-by: jrimmer <jason@rimmer.net>
The old `into_vm()` called `std::mem::forget(self)` after taking the VM, permanently leaking the guard's `Arc<NetnsAllocator>` (and the shared-tap Arc/String on the stacked branch) on every successful BRANCH reinsertion. A long-running controller would accumulate one leaked allocation per BRANCH without bound. The fix: - `into_vm()` now takes the VM out (sets `self.vm = None`) and lets the guard drop normally instead of `mem::forget`. The remaining fields (the `Arc<NetnsAllocator>` and bookkeeping strings) are released on drop, decrementing refcounts — no leak. - `Drop` checks `self.vm.is_some()` before killing the VM and releasing the netns index. When `into_vm()` was called, `self.vm` is `None`, so Drop skips the kill+release path (the index must stay ACTIVE because the VM was transferred to `live_vms`). Added a regression test (`vmnetnsguard_into_vm_does_not_leak_arc`) that constructs 50 consumed guards (the post-`into_vm()` state) and drops them, then asserts the `Arc::strong_count` returns to baseline. If the old `mem::forget` behavior were still present, the count would stay elevated.
b3b65f8 to
af658ef
Compare
WaylandYang
left a comment
There was a problem hiding this comment.
Thanks for working through the allocator lifecycle issues. I re-reviewed the rebased head: reservations are now atomic and cancellation-safe, committed ranges remain owned until teardown, workspace paths return 503 consistently, and VmNetnsGuard::into_vm() no longer leaks its Arc. The remaining controller-restart recovery concern is tracked separately and is not a blocker for this scoped allocator fix. Approved pending the rerun CI.
Replace check-then-act pick_netns_offset with NetnsAllocator: - reserve(n) scans the provisioned pool and marks indices under one lock; returns a RAII lease released on drop (spawn failure) or committed after live_vms registration (spawn success) - committed ranges become ACTIVE and stay owned until the controller releases them via release_index() when the VM leaves live_vms (delete/suspend) — a still-live VM's range is never handed out again (review #282 round 2) - shared by BOTH the sandbox (create_sandbox) and workspace (create/resume) spawn paths - bound derived from the provisioned pool (netns dir scan or injectable probe), not a magic MAX_OFFSET=256 — pools > 256 work - exhaustion surfaces as 503 Service Unavailable, not a generic 500 - tests: concurrent reservations (Barrier-held leases), pools > 256, commit-keeps-active + release_index frees, drop-releases, disk-probe discovery, HTTP 503 path
…pace 503 P0: resume_workspace bound _netns_reservation (underscore = unused) and never called commit(), dropping the reservation while the resumed VM was still live — re-opening the exact netns collision race #282 claims to fix (review #282 round 2). P1: workspace create/resume mapped netns exhaustion to 500 (server_error) instead of 503 (service_unavailable), inconsistent with create_sandbox. Introduced NetnsExhausted marker error, downcast to 503 in both workspace handlers. P2: DiskNetnsProbe now caches provisioned index set at construction (eliminates per-index stat under allocator locks). Teardown paths now kill firecracker (Vm::drop) before releasing the netns index, closing the window where a new spawn could enter forkd-child-N while the previous owner is still dying. release_netns_index logs a warning on unparseable netns strings. Tests: create_workspace_netns_exhaustion_returns_503, resume_workspace_netns_exhaustion_returns_503, concurrent_reservations_skip_committed_active.
…/suspend Review #282 round 3 (WaylandYang CHANGES_REQUESTED): 1. Move netns_alloc.reserve() from the async handler into the spawn_blocking task in create_sandbox. The old code reserved in the async context, so if the request future was cancelled while the blocking restore ran, the handler dropped the reservation (making indices available to another request) while the restore still used them. By owning the reservation in the blocking task, cancellation drops it only after the restore completes or fails. 2. Add VmNetnsGuard struct that kills the VM and releases the netns index on drop. Used in branch_sandbox and suspend_workspace to ensure netns cleanup survives async-handler cancellation and concurrent DELETE during the take-out window. When the handler is cancelled, the blocking task's return value (containing the guard) is dropped, triggering cleanup automatically. 3. Fix branch_sandbox DELETE-during-BRANCH path: the old code called drop(vm_back) without release_netns_index, permanently leaking the active index. The guard's Drop now handles this. 4. Fix suspend_workspace cancellation: the old code captured netns and released after drop only in the normal path. Cancellation dropped the task result without running this code. The guard's Drop handles both paths. 5. Add 3 regression tests verifying the allocator invariants the guard relies on: committed-index-released-then-reusable, uncommitted-reservation-blocks-concurrent-reserve, netns-index-lifecycle-reserved-active-free.
… round 3 fixes Review deeplethe#281 round 3 (WaylandYang CHANGES_REQUESTED) + rebase onto deeplethe#282 r3: 1. Reject shared-TAP batches with n>1 (503): when per_child_netns=false, all children share a single host tap fd. The tap lease is owned by the first child's sandbox id; deleting that child releases the lease while sibling VMs remain live, causing EBUSY on the next spawn. The common case is n=1; n>1 shared-TAP spawns are rejected until per-child tap ownership is modeled (review deeplethe#281 r3). 2. Extend VmNetnsGuard to release the shared-tap lease on Drop: the guard now carries optional shared_tap_owner + tap_owner_id. On Drop (cancellation or DELETE during BRANCH/suspend), it clears the owner if it matches. This ensures the tap lease is always released when the VM is killed, surviving handler cancellation (review deeplethe#281 r3 + deeplethe#282 r3). 3. Rebase shared-tap lease (SharedTapClaim, try_claim_shared_tap, release_shared_tap_if_owner) on top of deeplethe#282 r3's cancellation-safety changes. The tap claim is now made inside spawn_blocking alongside the netns reservation, returned uncommitted with the 3-tuple (ForkResult, Option<NetnsReservation>, Option<SharedTapClaim>), and committed after live_vms.insert. Both NetnsExhausted and SharedTapBusy are mapped to 503. 4. branch_sandbox and suspend_workspace guards now pass shared_tap_owner and the sandbox id, so the guard's Drop releases both netns and tap on cancellation or DELETE during the take-out window.
… round 3 fixes Review deeplethe#281 round 3 (WaylandYang CHANGES_REQUESTED) + rebase onto deeplethe#282 r3: 1. Reject shared-TAP batches with n>1 (503): when per_child_netns=false, all children share a single host tap fd. The tap lease is owned by the first child's sandbox id; deleting that child releases the lease while sibling VMs remain live, causing EBUSY on the next spawn. The common case is n=1; n>1 shared-TAP spawns are rejected until per-child tap ownership is modeled (review deeplethe#281 r3). 2. Extend VmNetnsGuard to release the shared-tap lease on Drop: the guard now carries optional shared_tap_owner + tap_owner_id. On Drop (cancellation or DELETE during BRANCH/suspend), it clears the owner if it matches. This ensures the tap lease is always released when the VM is killed, surviving handler cancellation (review deeplethe#281 r3 + deeplethe#282 r3). 3. Rebase shared-tap lease (SharedTapClaim, try_claim_shared_tap, release_shared_tap_if_owner) on top of deeplethe#282 r3's cancellation-safety changes. The tap claim is now made inside spawn_blocking alongside the netns reservation, returned uncommitted with the 3-tuple (ForkResult, Option<NetnsReservation>, Option<SharedTapClaim>), and committed after live_vms.insert. Both NetnsExhausted and SharedTapBusy are mapped to 503. 4. branch_sandbox and suspend_workspace guards now pass shared_tap_owner and the sandbox id, so the guard's Drop releases both netns and tap on cancellation or DELETE during the take-out window. Signed-off-by: jrimmer <jason@rimmer.net>
Related issue: #286
Problem
pick_netns_offsetcould return an offset beyond the provisionedforkd-child-Nset. When all namespaces are in use (e.g. after a client restart orphans warm VMs), a new spawn landed inforkd-child-11(which doesn't exist) and failed with a confusing"socket never appeared within 10s"loop that never self-healed.Fix
/var/run/netns/forkd-child-K) — the filesystem is the source of truth for what's provisioned.Nonewhen the pool is exhausted; both call sites (sandbox spawn + workspace spawn) surface a clearnetns pool exhaustederror pointing atscripts/netns-setup.shinstead of silently spawning into nothing.MAX_OFFSET=256) so a completely unprovisioned host fails fast.Testing
cargo check -p forkd-controllerpasses.