Skip to content

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
mainfrom
fix/windows-stuck-vmm-teardown
Open

Lupus wants to merge 29 commits into
mainfrom
fix/windows-stuck-vmm-teardown

Conversation

@Lupus

@Lupus Lupus commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

On Windows a sandbox is two openvmm.exe processes: the launcher izba records in state.json and an openvmm vm worker that holds the WHP partition, the disk images and the sockets. izba stop verified only the launcher, and only through GetExitCodeProcess — which TerminateProcess satisfies at once, while the kernel-side teardown that releases the handles can hang. Spike #249 caught a worker stuck that way: stop returned 0, deleted state.json and the run dir, status said stopped, and rm --force later failed with a bare Access is denied (os error 5) while the worker still held rw.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 iff pid_alive, i.e. exactly today's gate. It enters the product through the existing liveness::Probes seam, so status, ls, the daemon's stale-state reaper and start'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 of stopped.
  • 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, keeping state.json, and telling the user what to do (Run izba stop <name> again … only a host reboot releases).
  • izba rm: without --force reports the degraded state (use force to remove); --force goes through the same sweep + gate and gives the same refusal instead of a raw OS error. A Windows PermissionDenied on the tombstone rename now says a file inside is still open.
  • izba start on a stuck sandbox refuses with the same explanation instead of exiting 0 as "already running".
  • The recycled-pid hazard of a PPID walk is guarded: a launcher recorded before the current boot has no tree (boot = creation time of pid 4; if unreadable the guard is disabled, never estimated), and when a different process now holds the launcher pid, everything created at or after it is the holder's. The filter is a pure, Linux-tested function.

Changes mapped to the Acceptance Criteria

  • Every openvmm.exe of the sandbox (launcher and worker) must be fully gone, not just carrying an exit code, before state.json/run dir removal — procmgr::tree_survivors (unix.rs, windows.rs, new survivors.rs) + stop_locked_with's tree-wide gate after the kill block on both paths (killed now / found already exited).
  • stop exits non-zero, names the pid, says the disks are held, says what to do — sandbox::disks_held_error, pinned by assert_eq! on the full text (singular and plural).
  • state.json preserved; status not a clean stop — assess rule 2b (Degraded, never Stopped), so reap_stale_stopped keeps the file; pinned by the retry test stop_refuses_when_the_launcher_is_already_gone_but_its_worker_survives.
  • rm incl. --force fails with the same explanation, never raw Access is denied — remove_with + rename_for_removal_error; tests for both arms.
  • Regression test through a seam/fake, no unkillable process — StuckTeardownProbes/ResweepProbes fake only the survivors verdict; the kill stays real (spawn_sleep + wait_dead). The re-sweep call site is pinned through Probes::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).
  • Reproduction recipe recorded — docs/spikes/0003-windows-openvmm-worker-teardown-hang.md + hack/spike/stuck-vmm-teardown-check.ps1 + a docs/testing.md §8 pointer.
  • Written conclusion on why the guest did not power off and why the worker hangs — spike doc findings 3–4: the worker's last thread sits in a kernel Wait:Executive inside the WHP/vid.sys partition teardown, which TerminateProcess cannot interrupt (outside izba's control; detect and report); the guest's console shows neither the usb detach's disconnect line nor reboot: Power down (printed only after device_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 — stop always escalates to kill #325.
  • Normal stop not slower; graceful-then-kill flow and privileges unchanged — one Toolhelp snapshot + zero-timeout waits; no new sleeps on the normal path; PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE only.

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 — status degraded, stop and rm --force exit 1 with the refusal, state.json kept. 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). stop on 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

Known, left as is: a stuck izba start now 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

  • All six gates green on the final tree: cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, musl izba-init build, x86_64-pc-windows-gnu check + clippy -D warnings.
  • TDD throughout (every new test seen RED first); per-task reviews plus a whole-branch review; the Windows-only tests execute in CI.
  • Plan: docs/superpowers/plans/2026-10-08-windows-stuck-vmm-teardown.md.

Closes #319

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The latest update appears safe to merge; no new blocking issue was established.

Summary

The PR keeps state.json and 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| F
Loading

Reviews (12) · Last reviewed commit: "test(procmgr): the pre-boot record is dr..." · Reviewed by Greptile

@Lupus Lupus added this to the v0.1.0 (MVP) milestone Oct 8, 2026
Comment thread crates/izba-core/src/procmgr/windows.rs Outdated
Comment thread crates/izba-core/src/procmgr/windows.rs Outdated
Comment thread hack/spike/stuck-vmm-teardown-check.ps1
Comment thread crates/izba-core/src/sandbox.rs Outdated
@greptile-apps

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>
Comment thread crates/izba-core/src/sandbox.rs
Lupus and others added 22 commits October 9, 2026 11:58
… 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
Lupus force-pushed the fix/windows-stuck-vmm-teardown branch from e1a496f to 25685a5 Compare October 9, 2026 07:58
Comment thread crates/izba-core/src/procmgr/survivors.rs
… 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>
Lupus and others added 2 commits October 9, 2026 13:23
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>
Comment thread crates/izba-core/src/procmgr/windows.rs Outdated
Lupus and others added 4 commits October 9, 2026 14:02
…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>
@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: a killed OpenVMM worker stuck in process teardown goes unnoticed — stop reports success while the disks stay locked

1 participant