fix(controller): serialize concurrent spawns to avoid tap collision - #281
Conversation
cd2d753 to
8e00c94
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:
No functional changes were introduced by the rewrite; the diff against |
WaylandYang
left a comment
There was a problem hiding this comment.
The mutex does not make allocation/spawn atomic, so the reported collision is still reachable:
netns_offsetandwork_dirare computed before acquiringspawn_mutex. Two concurrent per-child-netns requests can both choose the same offset/work directory, then run serially with the second using its stale choice after the first VM has been registered.- Workspace create/resume goes through
spawn_one_for_workspaceand bypasses this mutex entirely. - For the shared-TAP path, serializing only the restore attempt does not release the first live VM's TAP fd; the next active VM can still hit
Resource busyafter it acquires the mutex.
Please centralize spawn allocation as an atomic reservation/lease shared by sandbox and workspace paths, hold it through successful live_vms registration (with rollback on failure), and define the shared-TAP policy so a second live owner cannot be launched. Add a concurrent-spawn regression test that would fail with the current pre-lock allocation.
…e#281) create_workspace and resume_workspace call spawn_one_for_workspace, which restores onto the same shared forkd-tap0 — but neither took spawn_mutex, so a workspace spawn could still race create_sandbox (or another workspace spawn) and hit "Resource busy". Both paths now take the same spawn mutex across spawn+registration. Also moves the netns reservation inside the create_sandbox critical section so offset pick, restore, and live_vms registration are one atomic unit (no stale-offset window between the pick and the lock).
8e00c94 to
c862a5c
Compare
…e#281) create_workspace and resume_workspace call spawn_one_for_workspace, which restores onto the same shared forkd-tap0 — but neither took spawn_mutex, so a workspace spawn could still race create_sandbox (or another workspace spawn) and hit "Resource busy". Both paths now take the same spawn mutex across spawn+registration. Also moves the netns reservation inside the create_sandbox critical section so offset pick, restore, and live_vms registration are one atomic unit (no stale-offset window between the pick and the lock).
c862a5c to
aacc4cc
Compare
|
Thanks — and apologies for the earlier commit attribution noise; the branch has been rewritten so all commits are authored by me (jrimmer). You're right on both points, and they're fixed across commits
Shared-TAP policy (explicit): a second live owner cannot be launched. The single host tap is opened during restore; Tradeoff / future opportunity (option b): if per-VM TAPs become desirable (parallel spawn throughput without serialization), the allocator pattern from #282 (atomic reservation + RAII lease, injectable pool) is exactly the shape a per-VM tap allocator would take — fmt/clippy/tests green. |
aacc4cc to
588bf18
Compare
|
Thanks for rebuilding the branch; the diff is scoped again and CI is green. The shared-TAP ownership blocker remains, though. spawn_mutex serializes restore calls, but it does not enforce shared-TAP ownership for the lifetime of a live VM. After the first restore and registration complete, the guard is released while that Firecracker process can still own forkd-tap0; a second request may then acquire the mutex and still fail with EBUSY. Thus the statement that a second live owner cannot be launched is not enforced by this implementation. There is also a cancellation gap: the mutex guard remains in the async handler rather than being owned by the spawn_blocking task. If the handler is cancelled, the guard is dropped while the blocking restore continues, allowing another restore to overlap it. For per_child_netns=true, distinct namespaces contain distinct same-named TAPs, so globally serializing those requests is unnecessary and can hide the allocator problem addressed by #282. Please first land a corrected #282 and re-check the original reproduction. If a shared-TAP path still needs protection, either maintain an explicit owner lease until VM teardown and reject or queue another shared-TAP owner, or allocate distinct TAP/netns ownership. Please add a regression test with the first shared-TAP VM still live when the second request arrives, plus coverage for cancellation/guard lifetime; a test covering only simultaneous handler entry is insufficient. |
… (review deeplethe#281 round 2) forkd-tap0 can be attached by ONE live firecracker at a time. The previous fix serialized the restore call with a mutex, which did NOT prevent a second VM from grabbing the tap after the first VM was already live — the first VM keeps the tap fd for its whole lifetime, so the second spawn would hit Resource busy even after acquiring the mutex. Replace the mutex with an explicit owner lease: - shared_tap_owner: Arc<Mutex<Option<String>>> holds the live owner's sandbox id; a second shared-tap spawn is rejected with 503 instead of racing into an EBUSY retry loop. - The claim is made INSIDE the spawn_blocking task (cancellation-safe: a dropped async handler cannot release the claim while the blocking restore continues) and committed on successful restore. - Released only when the VM leaves live_vms (delete sandbox, delete workspace, suspend workspace). - per_child_netns=true spawns skip the lease entirely: each child netns has its own tap, so parallel per-netns sandboxes are unaffected. - Regression tests: second shared-tap owner → 503; per-netns ignores the lease; lease claim/release/drop lifetime semantics.
588bf18 to
b8d089f
Compare
|
Thanks for the thorough review — all three points verified, and the fix is now in (commit You were right: a mutex that only serializes the restore call does NOT enforce tap ownership. The first VM keeps What changed — explicit owner lease:
Tests added: second shared-tap owner while the first is live → 503 (names On the shared-tap policy itself: the lease enforces "a second live owner cannot be launched" by rejecting rather than queueing — explicit, immediate, and honest about the one-owner-per-tap kernel constraint. The alternative (allocate distinct TAP per sandbox, i.e. per-VM taps) is a clean follow-up and the better long-term answer for multi-sandbox hosts; it's a bigger change (tap lifecycle + netns wiring) so I'd suggest tracking it separately. Happy to open that as an issue if you'd like. |
…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.
b8d089f to
b8e91d0
Compare
Cross-PR merge conflict (resolved)#281 retained P1: Tap lease committed inside spawn_blocking before registration (fixed)The tap lease was committed inside the Fix: The P1: Teardown releases tap lease before killing VM (fixed)
Fix: All teardown paths now P2:
|
…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.
b8e91d0 to
569c31e
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.
569c31e to
d666473
Compare
WaylandYang
left a comment
There was a problem hiding this comment.
The explicit owner cell fixes the cross-request restore race, but the current shared-TAP lifetime is still incorrect for batches and take-out operations:
- For
n > 1 && per_child_netns=false, one request starts multiple Firecracker children against the sameforkd-tap0while holding one owner token. This does not prevent sibling collisions insiderestore_many_with. If the batch does start, the token is assigned to the first child ID; deleting that first child clears the lease even though its siblings remain live on the shared tap. Either reject shared-TAP batches above one or model ownership for every live child/use distinct TAPs. - BRANCH temporarily removes the VM from
live_vmswithout transferring the shared-TAP lease into an RAII owner. Cancellation, or DELETE removing the registry entry during that window, drops the VM after the task but never clearsshared_tap_owner, wedging all future shared-TAP spawns until restart. Workspace suspend has the same cancellation dependency. - This branch inherits #282''s analogous netns cancellation/leak paths, so it should remain stacked and blocked until those ownership semantics are corrected.
Please add tests for a shared-TAP n=2 batch and deletion of each sibling, plus cancellation and DELETE during BRANCH/SUSPEND.
WaylandYang
left a comment
There was a problem hiding this comment.
One lifecycle gap remains across controller process restarts. On startup, Registry::reconcile() keeps records whose PIDs are alive, but live_vms and shared_tap_owner are initialized empty. If the controller crashes while Firecracker survives, the restarted controller therefore considers forkd-tap0 unowned and can admit a new shared-TAP VM even though the orphan still owns it. The retained registry record is not enough: DELETE has no reconstructed Vm handle with which to terminate that process, and a bare /proc/<pid> existence check is vulnerable to PID reuse.
Please define and test restart recovery: either verify process identity, terminate orphan Firecracker processes, and remove their registry entries before accepting spawns; or genuinely adopt them and rebuild TAP ownership/live handles from durable state. Until then, the lease is only correct within one controller process lifetime.
… 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.
d666473 to
e55ca5b
Compare
Round 3 fixes — N>1 shared-TAP rejection and BRANCH/suspend tap lease cancellationAll CHANGES_REQUESTED findings from the 2026-08-12 review are addressed in commit e55ca5b (rebased onto #282 r3). 1. Reject shared-TAP batches with n>1 (503)When 2. Extend
|
… 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.
e55ca5b to
99bea5a
Compare
CI fixRebased on #282's latest tip (includes the
All round 2 CHANGES_REQUESTED findings remain addressed:
The COMMENTED controller-restart gap 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.
One ownership-transfer bug remains in VmNetnsGuard::into_vm(). The successful BRANCH path takes the Vm and then mem::forget(self), so every reinsertion into live_vms permanently leaks the guard fields: the NetnsAllocator Arc, shared_tap_owner Arc, and tap_owner_id String. Repeated BRANCH operations make this an unbounded daemon-lifetime leak.
Please let the guard be dropped normally after taking the VM, and make Drop release netns/TAP ownership only when self.vm is still Some (meaning the guard still owns and kills the VM). This preserves the active leases on successful transfer while dropping the bookkeeping allocations. Please add a regression test that observes the Arc strong counts or an equivalent drop sentinel across repeated into_vm transfers. This branch should remain stacked on the corrected #282 and merge after it.
Fix: VmNetnsGuard::into_vm() Arc leakThe old Fix:
Regression test: |
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you for resolving the shared-TAP ownership and Arc-lifetime findings. #282 has now landed on main, but GitHub cannot rebase this branch automatically because the stacked allocator history conflicts with the merged implementation. Please rebase onto current main and retain only #281's shared-TAP lease/batch-validation changes; do not reintroduce the old allocator commits. Once the resulting diff is scoped, CI is green, and the ownership/cancellation tests still pass, I expect this to be ready for final approval.
… 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.
cb7472f to
e1e320b
Compare
|
Rebased onto current The branch now contains only the shared-TAP lease and batch-validation changes:
Verified: |
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you for rebasing this onto current main and removing the duplicated allocator history. I re-reviewed the resulting diff: the shared-TAP owner lease, n > 1 shared-TAP rejection, owner-token check, and cancellation/teardown transfer through VmNetnsGuard now look correct, and CI is green. I do not see a remaining code-level blocker in this scoped PR.
One repository requirement still blocks approval: commit 768de13 has no Signed-off-by trailer. README's contribution instructions require every commit to be signed off with git commit -s. Please rewrite/squash the branch so every resulting commit carries your DCO sign-off, force-push, and let CI rerun. After that, this should be ready for approval.
… 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>
Signed-off-by: jrimmer <jason@rimmer.net>
e1e320b to
01aba1a
Compare
WaylandYang
left a comment
There was a problem hiding this comment.
Re-reviewed the current head. The shared-TAP owner lease is held through spawn and registration, n > 1 shared-TAP use is rejected, cancellation safely releases reservations, and delete/teardown ordering prevents early reuse. CI is green and I found no remaining correctness blocker. Approved.
Squash of 10 commits on fix/restore-path-resilience for rebasing onto current dev (which now contains deeplethe#281's shared-TAP lifecycle). Original commits: - 858f63b fix(vmm): restore-path resilience for issue deeplethe#301 - ce3d800 fix(vmm): apply review fixes for deeplethe#301 - 0d73362 fix(vmm): make DEFAULT_SOCKET_WAIT_SECS pub for cross-crate use - e820848 fix(controller): use Vm::pid() accessor, not private field - 1ddf40c fix(controller): drop needless borrow in workspace orphan scan call - ca52820 fix(vmm): partial-success contract + concurrent socket wait for restore_many_with - 9e863de fix(controller): expose orphan-firecracker detection as a metric - 2aca840 test(vmm): integration test for partial-success restore - 9342e7a test(vmm): accept Restore or SocketWait phase in partial-success test - f2751b9 fix(vmm): review fixes for deeplethe#301 — indexed success, valid test topology, docs Signed-off-by: jrimmer <jason@rimmer.net>
Squash of 10 commits on fix/restore-path-resilience for rebasing onto current dev (which now contains deeplethe#281's shared-TAP lifecycle). Original commits: - 858f63b fix(vmm): restore-path resilience for issue deeplethe#301 - ce3d800 fix(vmm): apply review fixes for deeplethe#301 - 0d73362 fix(vmm): make DEFAULT_SOCKET_WAIT_SECS pub for cross-crate use - e820848 fix(controller): use Vm::pid() accessor, not private field - 1ddf40c fix(controller): drop needless borrow in workspace orphan scan call - ca52820 fix(vmm): partial-success contract + concurrent socket wait for restore_many_with - 9e863de fix(controller): expose orphan-firecracker detection as a metric - 2aca840 test(vmm): integration test for partial-success restore - 9342e7a test(vmm): accept Restore or SocketWait phase in partial-success test - f2751b9 fix(vmm): review fixes for deeplethe#301 — indexed success, valid test topology, docs Signed-off-by: jrimmer <jason@rimmer.net>
Related issue: #285
Problem
When two
POST /v1/sandboxesspawns run concurrently, both firecracker processes race to open the single shared host tap (forkd-tap0). One fails with:The existing retry loop in
create_sandboxthen re-attempts against a firecracker that was already started, surfacing:That corrupt sandbox is unusable — any subsequent exec against it fails with
Connection reset by peer. In a CI runner that grants sandboxes concurrently (e.g. multiple workflow jobs), this intermittently fails jobs.Fix
Hold a spawn mutex across the restore in
create_sandbox, so concurrent spawns serialize and never collide on the tap. Each spawn still restores its N children in parallel internally, so this is a correctness fix, not a throughput cap.Uses
tokio::sync::Mutex(notparking_lot) so the guard isSendand the axum handler future staysSendfor theHandlerbound.Testing
cargo test -p forkd-controller: 78 passed, 0 failedforkd-vmmchain test fails (unrelated, fails on clean main too)