Repository navigation
fix(core): refuse to call a sandbox stopped while an OpenVMM worker still holds its disks (#319) - #328
Open
Lupus wants to merge 29 commits into
Open
fix(core): refuse to call a sandbox stopped while an OpenVMM worker still holds its disks (#319)#328Lupus wants to merge 29 commits into
Lupus wants to merge 29 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
Lupus
added a commit
that referenced
this pull request
Oct 8, 2026
The incremental mutation gate on #328 reported 16 survivors. Each class is now killed or structurally removed: - reconcile.rs PidProbes::tree_survivors (3): #[mutants::skip] with a justification. It is a pass-through to procmgr::tree_survivors, which is mutation-tested in izba-core, and is observable only with a live sandbox (daemon e2e). - liveness.rs default Probes::sweep_tree_survivors -> Ok(()) (1): killed by real_probes_sweep_tree_survivors_kills_the_recorded_process. RealProbes relies on the default, which must really kill a sleep. - sandbox.rs RealProbes::tree_survivors -> vec![] (1): killed by real_probes_tree_survivors_reports_the_recorded_process_while_it_lives. - windows.rs `| -> ^` in open_sync_query / terminate_identity (3): the access masks are disjoint bits, so these mutants are equivalent. The masks are hoisted into QUERY_SYNC_ACCESS / TERMINATE_IDENTITY_ACCESS consts, whose initialisers cargo-mutants does not mutate. - windows.rs boot_time -> Some(0)/Some(1) (2): the boot-time tests now also assert boot > FILETIME_2000 (2000-01-01 UTC), so a placeholder boot time fails them. - sandbox.rs stop_locked_with poll loops (6): both deadline loops are replaced by a counted poll_until helper, so no Instant comparison is left to mutate. The remaining mutants are killed by stop_polls_a_draining_tree_until_it_is_gone_and_returns_promptly, stop_graceful_waits_for_the_guest_to_power_off_instead_of_killing and the two direct poll_until tests. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… teardown On Windows a terminated process carries an exit code immediately, while the kernel-side teardown that releases its handles can hang; the process object is signaled only when that completes. tree_survivors asks exactly that, for the recorded root and its worker children, instead of GetExitCodeProcess. Unix keeps the root-only, pid_alive-based semantics stop relied on so far. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ost descendant CREATE_NO_WINDOW and bare Command launches can leave a console host (conhost.exe) as a descendant of the console client. Exact-tree assertions could then fail, and the orphaned-worker test could capture only conhost and pass without proving the sleep worker is reported. - tests 1/2 assert on the pid under test (contains / !contains) and keep a bounded wait for the whole tree to drain. - test 3 resolves image names from a Toolhelp snapshot (test-only helper), waits for a sleep.exe descendant, and asserts that pid is reported. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ded, not stopped Probes gains tree_survivors (default: the root while pid_alive), and assess turns 'launcher gone but a process of its tree still holds the disks' into Degraded with a reason that names the pid. Stopped is what let the stale-state reaper drop state.json and a later start double-boot against held disks. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ocess still holds its disks stop and rm gate on procmgr::tree_survivors after the kill — the recorded launcher AND its worker children — instead of the launcher's exit code, and return one refusal that names the pid, says the disks are still held, keeps state.json and tells the user what to do. The same fact reaches status/ls through assess (Degraded, never Stopped), so the stale-state reaper no longer wipes a stuck sandbox's state and a retry cannot fall through to cleanup. A failed tombstone rename on PermissionDenied now points at an open file instead of the bare OS error. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, pin the rm --force survivor path
disks_held_error now agrees in number with several survivors ("still hold",
"once pids … have left"); the singular text is byte-identical. The "file
inside is still open" rename hint is Windows-only: on Unix PermissionDenied
from rename(2) is a parent-directory permission problem. Adds tests for the
launcher-alive rm --force path where a worker lingers after the kill, the
plural/singular wording, the Unix rename context, and that a refused stop
keeps the run dir.
Refs #319
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st never powered off (#319) Records the surviving evidence from the 2026-10-02 run (the worker is still present: one thread in a kernel Executive wait, rw.img exclusively held), concludes the worker hang is a WHP/vid.sys teardown wait TerminateProcess cannot interrupt, and the missing power-off is a guest-side vhci-at-shutdown defect (separate follow-up). Adds the real-host check script for the #319 contract and a runbook pointer. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…census, no destructive rm unless stop refused Capture izba output via ForEach-Object + Out-String -Width 4096 so a long stderr line is not wrapped mid-token before the -match checks. Guard the HasExited read in the census against processes the caller cannot open. Run rm --force only when the preceding stop was refused (it would really delete a healthy sandbox); otherwise record the three rm checks as FAIL. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…worker Eleven of eleven checks of hack/spike/stuck-vmm-teardown-check.ps1 pass on the real 2026-10-02 survivor (pid 30620): status reads degraded, stop and rm --force exit 1 with the actionable refusal, state.json is kept and the adoption sweep does not reap it. Records what had to be reconstructed first (an outside cleanup emptied the spike's data root the same day), links the guest-side follow-up #325, and notes the daemon-inherits-stdout-pipe hang seen on the way. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ime and pid-holder guards Once the recorded launcher is dead, the Windows PPID walk keys on its pid NUMBER, which Windows hands out again. After a host reboot every process postdates the recorded start time, so anything whose PPID equals the old launcher pid (plus its children) read as a surviving VMM worker and wedged the sandbox: stop/rm refused and told the user to reboot — which they just had. A long-lived process reusing the pid with children did the same. The decision now lives in a pure, FFI-free filter (`procmgr::survivors::filter_tree_survivors`, unit-tested on every host) with two guards: - boot guard: a launcher created before the current boot (the `System` process's creation time) has no surviving tree. An unreadable boot time disables the guard instead of estimating it — an estimate placing boot too late would declare a live tree pre-boot and reopen the double-boot hole. - pid-holder guard: when a different process now holds the launcher pid, candidates created at or after it are its children, not our workers (ours were created before our launcher died). `descendants_of` and `kill_pid`'s sweep are unchanged. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…what izba actually observed Review findings on #319: - A launcher that died on its own can leave its `openvmm vm` worker still RUNNING. `stop` killed only when the launcher or a sidecar was alive, so it never reached that worker and refused with "has been terminated ... only a host reboot releases" — false, and `rm --force` was refused by a pre-check before it could kill anything. `stop` now re-sweeps a tree whose launcher is already gone, skips the Shutdown RPC to a dead launcher, and polls the WHOLE tree (2 s, 20 ms steps) instead of the launcher pid before its gate. `rm` drops the pre-check: plain `rm` reports the real `degraded (...)` state, `rm --force` reaches the re-sweep. - The re-sweep is `procmgr::sweep_tree_survivors`, not `kill_pid`: with the launcher dead, `kill_pid`'s descendant sweep walks PPIDs by the pid NUMBER alone and would TerminateProcess the children of whatever process now holds that pid. The new sweep kills exactly what the guarded `tree_survivors` reports (Linux: `kill_pid` of the root-only tree). - `izba start` on a stuck sandbox answered `AlreadyRunning`, which the CLI turns into an exit-0 "already running". It now returns the disks-held error. - Wording states only what izba observed: `degraded (vmm process N outlived its launcher and still holds the disks)`, and the refusal says the process "from its last run is still present and holds the sandbox's disks", to run `izba stop` again, and that only a process which stays after that is stuck in kernel-side teardown. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…removes a sandbox refused for held disks The status/stop/rm checks now match the reworded reason and refusal (`outlived its launcher and still holds the disks`, `from its last run is still present and holds the sandbox's disks`, `Run `izba stop`` again). `rm --force` is attempted only when `izba stop` was refused BECAUSE the disks are held — any other non-zero stop (a busy lock, a daemon error) no longer leads into a removal that would really delete the sandbox. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
stop_locked_with's re-sweep of an orphaned worker (launcher already gone, tree_survivors non-empty) called the free procmgr function, which every sandbox test turned into a no-op via dead_identity(), so deleting the branch changed no test result. Route it through a new Probes::sweep_tree_survivors method (default: the real sweep) and replace the call-counting fake with one that records sweeps and drops its survivors once swept. The re-sweep test now asserts exactly one sweep; the dead-launcher-nothing-survives test asserts zero; the truly-stuck test asserts one sweep and the refusal. Also: the Windows pre-boot-record test cleans up with the guarded sweep_tree_survivors instead of the bare PPID kill_pid, which could hit a parallel test's worker on pid reuse; and document that the boot guard assumes a monotonic wall clock (a clock stepped back drops live trees), with the robust form tracked as a follow-up issue. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ard comments Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ards — 11/11 with the launcher's real creation time Three runs against the real stuck worker: run 1 (pre-guard binary, starttime 0) proved detection, not discrimination; run 2 with a forged start time read stopped because the dead launcher's process object is still reserved by the worker's handle and reports its real creation time, which the pid-holder guard correctly treats as a stranger; run 3 with that real creation time passes every check. Records that lesson in the recipe, the ~12 s cost of the re-sweep on a truly stuck worker, and that the boot guard is disabled for an unprivileged caller on this host (pid 4 is not openable). Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… process identities, soften the rename hint - start: read liveness first and run the stuck-teardown pre-check only when it is not Stopped, so a launcher that dies between the two reads (leaving its worker behind) gets the disks-held refusal instead of an AlreadyRunning the CLI turns into exit 0. - Windows sweep_tree_survivors now carries (pid, creation time) pairs and terminates through terminate_identity, which verifies the creation time on the same handle it kills through, so a pid recycled during an earlier survivor's bounded wait is never terminated. tree_survivors keeps its signature; kill_pid/terminate_quiet are unchanged. - Windows tests no longer require pid 4 to be openable: the boot-time test skips at runtime, and the pre-boot test asserts the disabled-guard contract (the orphan is still reported) when the boot time is unreadable. - The Windows rename-for-removal hint names a missing rename right as the other cause instead of asserting an open file. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ay remove a sandbox `rm --force` really deletes the sandbox if the worker finishes exiting after the refused stop, so it now runs only with IZBA_CHECK_RM=1 and the held-disks refusal. Without the opt-in the three rm checks print as SKIP and do not count as failures; the header warns to use a disposable sandbox. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The incremental mutation gate on #328 reported 16 survivors. Each class is now killed or structurally removed: - reconcile.rs PidProbes::tree_survivors (3): #[mutants::skip] with a justification. It is a pass-through to procmgr::tree_survivors, which is mutation-tested in izba-core, and is observable only with a live sandbox (daemon e2e). - liveness.rs default Probes::sweep_tree_survivors -> Ok(()) (1): killed by real_probes_sweep_tree_survivors_kills_the_recorded_process. RealProbes relies on the default, which must really kill a sleep. - sandbox.rs RealProbes::tree_survivors -> vec![] (1): killed by real_probes_tree_survivors_reports_the_recorded_process_while_it_lives. - windows.rs `| -> ^` in open_sync_query / terminate_identity (3): the access masks are disjoint bits, so these mutants are equivalent. The masks are hoisted into QUERY_SYNC_ACCESS / TERMINATE_IDENTITY_ACCESS consts, whose initialisers cargo-mutants does not mutate. - windows.rs boot_time -> Some(0)/Some(1) (2): the boot-time tests now also assert boot > FILETIME_2000 (2000-01-01 UTC), so a placeholder boot time fails them. - sandbox.rs stop_locked_with poll loops (6): both deadline loops are replaced by a counted poll_until helper, so no Instant comparison is left to mutate. The remaining mutants are killed by stop_polls_a_draining_tree_until_it_is_gone_and_returns_promptly, stop_graceful_waits_for_the_guest_to_power_off_instead_of_killing and the two direct poll_until tests. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Windows a CREATE_NO_WINDOW console client gets a hidden conhost.exe child, and the procmgr tree reports it as a descendant of the recorded process. That child also drains asynchronously after a kill. Assert that the recorded pid is or is not among the survivors rather than requiring the tree to be exactly [pid] or empty. This matches the Windows tests in procmgr/windows.rs. It still kills RealProbes::tree_survivors -> vec![], because contains fails on an empty vec. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…utable comparison poll_until was a counted loop (budget / step iterations). That had two consequences: 1. cargo-mutants' `/` -> `*` mutant turned 2 s / 20 ms into 40 000 steps of 20 ms, about 13 minutes of hanging per test run. It was "caught by timeout", but on the Windows mutation shard that cost pushed the job past its 60-minute limit. 2. The loop ignored time spent inside `done()`, which is a Toolhelp snapshot on Windows, so a stop could overrun its requested budget on a busy host. Rewrite it as a wall-clock deadline. The deadline test is `deadline.saturating_duration_since(now).is_zero()` instead of `<`/`<=`: an Instant comparison has an equivalent mutant nothing can kill, while this form leaves no comparison operator to mutate. No mutant of the body can hang, since every path reaches the deadline. The direct unit tests move from counted to deadline semantics (the never-done case now asserts elapsed bounds and at least two polls rather than an exact call count). Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion gate has no equivalent OR to flip QUERY_SYNC_ACCESS and TERMINATE_IDENTITY_ACCESS were ORs of disjoint flag bits, so cargo-mutants' `|` -> `^` mutants were semantically identical and unkillable (it mutates const initialisers, and #[mutants::skip] has no effect on a const). Write each mask as a single literal and pin it to the real flags in a cfg(windows) unit test, which the mutation gate does not mutate. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lupus
force-pushed
the
fix/windows-stuck-vmm-teardown
branch
from
October 9, 2026 07:58
e1a496f to
25685a5
Compare
… and re-sweep ignore unrelated images Review finding (PR #328): once the recorded launcher is dead, the Windows tree walk finds "our" workers by PPID on the launcher's pid number. If an unrelated program reused that pid, spawned a child and exited before the next `stop`, the pid is vacant again: the pid-holder guard has no holder to compare against, the child was created after the launcher so the boot and creation-time floors keep it, and `sweep_tree_survivors` hands it to `terminate_identity`, whose identity check passes because the recorded identity IS that child's own — izba killed someone else's process. izba launches exactly one VMM on Windows, OpenVMM, whose worker re-runs the same binary. `filter_tree_survivors` now takes the expected image name and drops every candidate whose Toolhelp `szExeFile` differs (ASCII case-insensitive), read from the same snapshot the PPID walk uses. `VMM_IMAGE_NAME` ("openvmm.exe") is the single constant the OpenVMM driver also locates the binary by. `tree_survivors`/`sweep_tree_survivors` fix it; `_of_image` seams let the Windows tests drive `cmd /c sleep` trees, and a new test pins that the production walk reports, and the production re-sweep kills, none of a non-openvmm orphan. `kill_pid`'s own descendant sweep is unchanged. Residual: two stale izba sandboxes whose launcher pids were reused by each other's launchers can still cross-report (both trees are openvmm.exe); the full fix records worker identities in state.json at start (follow-up issue). An `$IZBA_OPENVMM` override with a different file name would have its workers ignored. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The image guard expected `openvmm.exe` unconditionally, but the OpenVMM driver launches whatever `$IZBA_OPENVMM` names. With that override pointing at a binary of any other file name, the production walk ignored that VMM's workers, so `stop` could again call a sandbox stopped while a worker still held its disks — the unsafe direction. `tree_survivors`/`sweep_tree_survivors` now take the expected image from `vmm_worker_image()`: the lowercased file name of `$IZBA_OPENVMM` when set, else `VMM_IMAGE_NAME` (also the fallback for an override with no file name). It reads the variable rather than calling `find_tool`, which fails when the override's file no longer exists — falling back to the default name then would reintroduce the miss; only the name matters. The bundled and PATH launch paths both search for `VMM_IMAGE_NAME`. Residual: the variable is read by the stopping process, so a stop run with a different `$IZBA_OPENVMM` than the start can still miss the workers. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nts without mutable arithmetic The process snapshot walked the ANSI Toolhelp API (PROCESSENTRY32, an i8 szExeFile) and decoded the name as UTF-8. On a non-UTF-8 ANSI code page a non-ASCII $IZBA_OPENVMM override (openvmm-é.exe) yields an image name that never equals the Unicode name worker_image_for derives from the variable, so the image filter drops a surviving worker and stop reads the tree as gone while it still holds the disks. Walk with Process32FirstW/Process32NextW over PROCESSENTRY32W instead and decode szExeFile as UTF-16 up to its NUL, lowercased with the same str::to_lowercase the override side uses; a new test pins that a non-ASCII override matches its own worker's wide image name. descendants_in still carried the index loop whose `i += 1` and `||` mutants survived the mutation gate. Rewrite it as a BFS over a VecDeque with a `seen` set (insert returning false covers both a self-parented pid and an already-queued one), keeping the oldest-ancestor-first order. Pin the remaining operators with tree_survivors_of_a_childless_root_is_exactly_the_root: a childless sleep root plus an unrelated sleep spawned after it must yield exactly the root, which `ppid == parent` -> `!=` and either `&&` -> `||` break by adopting the bystander. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion can loop forever descendants_in adopts an entry when `ppid == parent && is_live_descendant && seen.insert(pid)`. With `seen.insert` as the middle operand, the second `&&` -> `||` mutant adopted every process created after the root without recording it in `seen`, so it was re-adopted on every pop, the queue grew without bound and the test run never ended. cargo-mutants records that as a TIMEOUT, which hack/mutants-report.py does not count as a kill, so the mutant "survived" even though tree_survivors_of_a_childless_root_is_exactly_the_root would fail it. Evaluate `seen.insert` last. Then: - `==` -> `!=`: every live unseen entry is adopted, each inserted on adoption, so at most once; the walk terminates and the bystander `sleep` (created after the root, different parent) is adopted, so the test fails. - first `&&` -> `||`: `(A || L) && S`; S still gates every adoption, so the walk terminates and the bystander is adopted. - second `&&` -> `||`: `(A && L) || S`; the bystander fails A and is adopted via S (inserted). A real child skips the insert only once, via its single parent's one pop, so no pid is adopted more than twice. Behaviour of the correct code is unchanged for a tree. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ant shard overran the 60-min cap three times The planner's 120 s/mutant Windows estimate dates from PR #300 (2026-09). On PR #328 (2026-10-09) the 5-shard plan put ~20 mutants on each Windows shard; shard 3 ran past the 60-minute job cap three times in a row (two 'runner lost communication' at ~55 min, one timeout cancellation) while its siblings took 25-33 min — i.e. >165 s/mutant once several mutants hit the auto-set ~62 s test timeout on top of a ~60 s rebuild on a slow hosted runner. 180 s/mutant yields 8 shards of ~13 mutants for this PR and keeps every shard inside the cap on a degraded runner. The cap stays at 36; the 641-mutant PR #300 scale now pins to the cap instead of 33. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… too — by the pid-holder guard Running the Windows tests unprivileged (pid 4 not openable, so the boot guard is disabled) failed tree_survivors_ignores_a_tree_recorded_before_the_current_boot: its None branch expected the orphaned worker to be reported, but the dead launcher's process object is still reserved by its worker's handle, so the forged start time differs from the pid holder's real creation time and the pid-holder guard drops the worker. Both branches now expect an empty tree, each for its own documented reason; the control assertion keeps proving the orphan is reported for the real record. CI runners are admin, which is why this never showed there. Refs #319 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
On Windows a sandbox is two
openvmm.exeprocesses: the launcher izba records instate.jsonand anopenvmm vmworker that holds the WHP partition, the disk images and the sockets.izba stopverified only the launcher, and only throughGetExitCodeProcess— whichTerminateProcesssatisfies at once, while the kernel-side teardown that releases the handles can hang. Spike #249 caught a worker stuck that way:stopreturned 0, deletedstate.jsonand the run dir,statussaidstopped, andrm --forcelater failed with a bareAccess is denied (os error 5)while the worker still heldrw.img— for days.This PR makes "stopped" mean what it says. One new process-manager primitive,
procmgr::tree_survivors, answers which processes of the VMM tree (launcher and descendants) still exist as un-torn-down kernel objects: on Windows the criterion is the process object being signaled (WaitForSingleObject(h, 0)), never the exit code; on Unix it is the root iffpid_alive, i.e. exactly today's gate. It enters the product through the existingliveness::Probesseam, sostatus,ls, the daemon's stale-state reaper andstart's already-running guard all see the same fact.Behaviour (Windows-visible; Linux normal path unchanged):
izba status/ls:degraded (vmm process <pid> outlived its launcher and still holds the disks)instead ofstopped.izba stop: with the launcher gone, first re-sweeps the guarded tree (an orphaned but still-running worker is killed through the same path), polls the whole tree, then refuses (exit 1) while anything survives — naming the pid, saying the disks are held, keepingstate.json, and telling the user what to do (Run izba stop <name> again … only a host reboot releases).izba rm: without--forcereports the degraded state (use force to remove);--forcegoes through the same sweep + gate and gives the same refusal instead of a raw OS error. A WindowsPermissionDeniedon the tombstone rename now says a file inside is still open.izba starton a stuck sandbox refuses with the same explanation instead of exiting 0 as "already running".Changes mapped to the Acceptance Criteria
state.json/run dir removal —procmgr::tree_survivors(unix.rs, windows.rs, newsurvivors.rs) +stop_locked_with's tree-wide gate after the kill block on both paths (killed now / found already exited).stopexits non-zero, names the pid, says the disks are held, says what to do —sandbox::disks_held_error, pinned byassert_eq!on the full text (singular and plural).state.jsonpreserved;statusnot a clean stop —assessrule 2b (Degraded, neverStopped), soreap_stale_stoppedkeeps the file; pinned by the retry teststop_refuses_when_the_launcher_is_already_gone_but_its_worker_survives.rmincl.--forcefails with the same explanation, never rawAccess is denied—remove_with+rename_for_removal_error; tests for both arms.StuckTeardownProbes/ResweepProbesfake only the survivors verdict; the kill stays real (spawn_sleep+wait_dead). The re-sweep call site is pinned throughProbes::sweep_tree_survivors. Windows real-process tests cover the signaled-vs-exit-code distinction, an orphaned worker, the boot/holder guards and the rename hint (they first execute in CI's Windows job).docs/spikes/0003-windows-openvmm-worker-teardown-hang.md+hack/spike/stuck-vmm-teardown-check.ps1+ adocs/testing.md§8 pointer.Wait:Executiveinside the WHP/vid.syspartition teardown, whichTerminateProcesscannot interrupt (outside izba's control; detect and report); the guest's console shows neither theusb detach's disconnect line norreboot: Power down(printed only afterdevice_shutdown()), pointing at the vhci (usbip) device at shutdown after traffic — filed as Windows/OpenVMM: guest fails to power off with a vhci (usbip) device attached or mid-detach after sustained traffic —stopalways escalates to kill #325.PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZEonly.Real-host validation
Validated against the actual stuck worker from the spike (pid 30620, still present on the host): 11/11 checks of the new script pass with the branch's
izba.exe—statusdegraded,stopandrm --forceexit 1 with the refusal,state.jsonkept. The doc records all three runs, including the one that failed because a forged launcher start time is, to the new guards, a recycled pid (the dead launcher's object is still reserved by the worker's handle and reports its real creation time).stopon a truly stuck worker takes ~12 s (the re-sweep's bounded wait) — the price of recovering an orphaned worker that is still running.Follow-ups filed
stopalways escalates to kill #325 — guest fails to power off with a vhci device attached/mid-detach after traffic (guest side).izba daemon runinherits the caller's stdout pipe — a pipedizbacommand blocks its consumer until the daemon exits #326 — an auto-spawnedizba daemon runinherits the caller's stdout pipe on Windows (found while validating: a pipedizbacommand blocks until the daemon exits).state.jsonso the boot guard survives a clock step-back.Known, left as is: a stuck
izba startnow also tears down that sandbox's daemon-side listeners (fails closed) and the daemon's progress line reads "already up but degraded" just before the error; the supervisor tick still respawns relays for a degraded sandbox (pre-existing).Testing
cargo test --workspace,cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --check, muslizba-initbuild,x86_64-pc-windows-gnucheck + clippy-D warnings.docs/superpowers/plans/2026-10-08-windows-stuck-vmm-teardown.md.Closes #319
🤖 Generated with Claude Code

Confidence Score: 5/5
The latest update appears safe to merge; no new blocking issue was established.Summary
The PR keeps
state.jsonand refuses cleanup while an OpenVMM launcher or worker still holds its resources. The latest update corrects a Windows test's expected result when the boot time cannot be read.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Check recorded launcher] --> B{Launcher alive?} B -->|Yes| C[Existing running checks] B -->|No| D[Check surviving VMM tree] D --> E{Any survivor?} E -->|No| F[Stopped: cleanup allowed] E -->|Yes| G[Degraded: keep state] G --> H[Stop sweeps and waits] H --> I{Any survivor remains?} I -->|Yes| J[Refuse stop and forced removal] I -->|No| FReviews (12) · Last reviewed commit: "test(procmgr): the pre-boot record is dr..." · Reviewed by Greptile