From 05aa9adfc6ffda474e4d32699d035134b6285b99 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:04:52 +0200 Subject: [PATCH 1/8] Disable partition_claim_departure: exits 0 having said none of its refusals, beside other guests Nightly run 36336701867, job guest (5), on PR #541's head 1c0f0c75, whose diff touches none of the partition-claim code: the departure role's guest_verdict failed with an empty stdout capture and exit 0, re-ran green alone immediately after. Same "reds beside other guests, green alone, no rate" shape already on record for partition_claim_gives_up, but a different failure signature (a guest_verdict stdout miss, not a kernel-log count mismatch), so it is filed separately and cross-linked rather than folded in. Co-Authored-By: Claude Opus 5.5 --- ...ts-clean-with-none-of-its-refusals-said.md | 83 +++++++++++++++++++ src/redlist.rs | 4 + 2 files changed, 87 insertions(+) create mode 100644 issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md diff --git a/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md b/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md new file mode 100644 index 0000000000..78d1a514e3 --- /dev/null +++ b/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md @@ -0,0 +1,83 @@ +--- +status: expected-red +kind: tooling +opened: 2026-09-27 +--- + +# `partition_claim_departure` exits 0 having said none of its refusals, beside other guests, green alone + +Seen on 2026-09-27 in the nightly run 36336701867, job `guest (5)`, on PR +#541's head `1c0f0c753fb2c4c2d01caf51dc67b7f92ca4cd8e` — a diff that touches +none of `tests/common/partclaim.rs`, `tests/toyos-rust-tests/src/bin/partition_claimant.rs` +or the kernel's partition-claim code: + +``` +FAIL partition_claim_departure: departure: the guest exited 0 having said 0 of its 1 refusals: + + FAIL partition_claim_departure (2s) +``` + +Re-run alone, immediately after, in the same session: green, with every role's +own line printed — + +``` + [partclaim] departure: 1 told; [kernel 0.757 cpu0] usb-quiesce: disk 0 SYNCHRONIZE CACHE ok + [partclaim] silent: 1 told; [kernel 0.710 cpu0] usb-quiesce: disk 0 SYNCHRONIZE CACHE ok + [partclaim] untold: 0 told; ... + PASS partition_claim_departure (7s) + ALONE partition_claim_departure: GREEN, and it was alone both times — nothing + the harness controls differed, so it failed once and passed once. That is a + rate and not a classification. +``` + +`cargo run -- --known-red partition_claim_departure` answered NO before this +row. + +## What is known + +The failure is `guest_verdict`'s (`tests/common/partclaim.rs`), on the +`departure` role — the first of the three `departed()` iterations in +`partition_claim_departure`. Its message, `"the guest exited 0 having said +{said} of its {refusals} refusals:\n{stdout}"`, prints with an empty tail: the +guest's own captured `stdout` held nothing at all — not one of the `departure` +role's own `println!`s (`"a write is reported and not flushed"`, `"the write +the device left under completed"`, the `refused with` lines `said()` prints, +or `"partition_claimant: PASS"`), yet the guest's exit code was 0. + +An exit of 0 rules out a panic on a wrong assertion (`departure()`'s +`assert_eq!`s all `panic!` on mismatch, and a panic does not exit 0), so the +guest's own logic is not shown to have run into an unexpected state — the +narrower reading, offered without more evidence, is that this run lost the +guest's captured output rather than that the guest produced none. `departed()` +also depends on a QMP hook (`MOVE_NOW`) firing off a marker the guest prints +mid-run, over a channel the CI shard shares with 23 other parallel tasks on a +4-core runner (`shard 5/12: 24 parallel task(s), 1 serial`); this suite's own +`tests/CLAUDE.md` already names both a channel that can silently lose a +stimulus and a demultiplexer for concurrent guests' console lines as classes +of defect this harness is exposed to, and either is consistent with what was +seen. Nothing here narrows which. + +This is the same family flagged in +`issues/boot-media/partition-claim-gives-up-reds-beside-other-guests-and-is-green-alone.md` +— same test area, same "reds beside other guests, green alone, no rate" +shape, and that file already widens its scope to `partition_claim_departure`. +It is filed separately rather than folded in because the failure signature +differs: that file's evidence is a kernel-log line count coming up short +(`"{count} flushes were told of the loss, not {told}"`, matched against +lines the kernel actually printed) on the `silent` role, attributed to a +hypothesised unscoped global fsync deadman race; this is a `guest_verdict` +failure on the `departure` role with the guest's entire stdout capture +missing, which that hypothesis does not by itself explain. Both may yet share +one root cause in how this harness handles concurrent guests under load; that +is unconfirmed. + +## Exit condition + +The mechanism — lost guest output under this shard's concurrency, a QMP hook +racing the marker it is keyed on, or something else — named and fixed, and a +test that turns red on it deterministically (not "green alone, red beside +other guests"). Then this row and its `src/redlist.rs` entry are deleted. + +## Owner + +The partition-claim code, held by the orchestrator. diff --git a/src/redlist.rs b/src/redlist.rs index f941c3eacc..e5d77595d6 100644 --- a/src/redlist.rs +++ b/src/redlist.rs @@ -43,6 +43,10 @@ pub const DISABLED: &[Disabled] = &[ Disabled { test: "hda_tone", issue: "issues/audio/hda-tone-phase-check.md" }, Disabled { test: "kill_while_blocked", issue: "issues/kernel/deferred-release-outlives-its-syscall.md" }, Disabled { test: "latency_wake", issue: "issues/build/latency-wake-reds-on-the-dev-host-at-a-rate.md" }, + Disabled { + test: "partition_claim_departure", + issue: "issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md", + }, Disabled { test: "quiesce_dump_holds_the_stopped", issue: "issues/kernel/quiesce-dump-holds-the-stopped-reds-wide-with-usb-transport-breaks.md", From 9371464adea91a3b81fa9c45aea163390bd72558 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:21:14 +0200 Subject: [PATCH 2/8] Fix the tap socket's SUN_LEN break, and disable i8042_mouse's third loss under host contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lan_mdns_answer: QEMU's chardev refused the tap sockets' path once a macOS $TMPDIR plus this harness's own lane path had already spent every byte of Darwin's 104-byte sockaddr_un.sun_path, leaving nothing for a filename (orchestrator's Fast-tier run on PR #537's head 06b926b1, a diff that touches neither). tests/common/segment.rs now names its sockets under /tmp directly, via the new toyos_build::socketpath, whose length never depends on $TMPDIR; open() unlinks both once connected, since /tmp is not this run's lane. i8042_mouse: a third sighting of the identical shape already tracked in issues/build/parallel-tests-red-under-other-suites.md's own entry for this test (872/876 packets, never more than the pacing's 12-of-16-byte bound outstanding) — inside the bound the first fix installed, so not that mechanism. Extended that entry with today's evidence and filed issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md for the redlist row, naming the plausible mechanism this tree's own qemu.rs already documents for the keyboard path: QEMU's PS/2 queue silently drops what a guest a starved host has not scheduled has not drained, which MOUSE_LEAD does not guard against the way the keyboard's own pacing against the guest's echo does. Co-Authored-By: Claude Opus 5.5 --- .../parallel-tests-red-under-other-suites.md | 23 +++++++ ...se-loses-a-packet-under-host-contention.md | 68 +++++++++++++++++++ src/lib.rs | 1 + src/redlist.rs | 4 ++ src/socketpath.rs | 58 ++++++++++++++++ tests/common/segment.rs | 15 ++-- 6 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md create mode 100644 src/socketpath.rs diff --git a/issues/build/parallel-tests-red-under-other-suites.md b/issues/build/parallel-tests-red-under-other-suites.md index 04047234b5..30407759ac 100644 --- a/issues/build/parallel-tests-red-under-other-suites.md +++ b/issues/build/parallel-tests-red-under-other-suites.md @@ -50,6 +50,29 @@ changes. re-run. So it is not a tree difference and it is not gone — one packet in a thousand is still being lost, or still being counted wrong, under a host carrying two suites. + **A third sighting, 2026-09-27**, the orchestrator's Fast-tier run on PR + #537's head `06b926b1` (a diff that renames paths only and touches neither + the i8042 driver nor this test): `872 pointer events reached userland out of + 876 packets injected, never more than 4 of them (12 bytes) outstanding + against a 16-byte device queue` — the identical shape and the identical + bound, four packets short this time rather than one. `cargo run -- + --known-red i8042_mouse` answered NO. The host was carrying at least three + other worktrees' builds at the moment it fired + (`[host-builds] … all 4 held by 4 holder(s)`), consistent with this file's + keyboard-side finding elsewhere in this tree (`tests/common/qemu.rs`'s + `QmpInput::type_burst`): QEMU's PS/2 queue — the same `QEMU_PS2_QUEUE` + constant the mouse pacing budgets against — drops what a guest whose vCPU the + host has not run for a couple hundred milliseconds has not drained, silently + and one byte at a time. `MOUSE_LEAD` bounds what the *host* holds + outstanding; nothing bounds how long a starved host leaves it there before + the guest is scheduled again, which is a gap the keyboard's own bound does + not close either — it is closed by pacing against the guest's echo, which + the mouse path does not do. **Not established as this mechanism**: it + explains a loss under host contention without contradicting anything in the + capture, but nothing here identifies which byte QEMU actually dropped or + when. Disabled for this reason in `src/redlist.rs` + (`issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md`) + rather than re-run away a third time. - **`i8042_absent`** — same session, same shape, and it is `Sched::Serial` already, so intra-suite width is not what reaches it. The verdict is the guest's own `Boot: complete` on two boots with a 300 ms allowance; the landing diff --git a/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md b/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md new file mode 100644 index 0000000000..ee1986d7e3 --- /dev/null +++ b/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md @@ -0,0 +1,68 @@ +--- +status: expected-red +kind: tooling +opened: 2026-09-27 +--- + +# `i8042_mouse` loses a packet under host contention, three sightings now + +Seen on 2026-09-27 in the orchestrator's Fast-tier run on PR #537's head +`06b926b1` — a diff that renames paths only and touches neither the i8042 +driver nor this test: + +``` +FAIL i8042_mouse: 872 pointer events reached userland out of 876 packets +injected, never more than 4 of them (12 bytes) outstanding against a 16-byte +device queue +``` + +`cargo run -- --known-red i8042_mouse` answered NO. This is the third recorded +sighting of the identical shape and bound (the first two, 2026-08-06/07, are +`issues/build/parallel-tests-red-under-other-suites.md`'s `i8042_mouse` entry, +which this file extends rather than duplicates — read it for the full +history, including the fix that closed the first, different mechanism: a +pacing lead wide enough to make QEMU sum motion it had no room to queue). + +## What is known + +The test's own design (`tests/toyos.rs`'s `i8042_mouse`) paces its injection so +the host never holds more than `MOUSE_LEAD` (4 packets, 12 of the device's 16 +bytes) outstanding, on the stated premise that staying inside what the device +holds should leave no loss to explain away. That bound was already the fix for +the first two sightings' mechanism, and this loss is inside it, so it is not a +recurrence of that one. + +`tests/common/qemu.rs`'s own doc comment on `QmpInput::type_burst`, written for +the keyboard path and citing the same `QEMU_PS2_QUEUE` constant this test +budgets against, names a mechanism this test does not guard against: "a guest +whose vCPU the host has not run for a couple hundred milliseconds drains none +of them — at which point the queue starts dropping, silently and one byte at a +time." `MOUSE_LEAD` bounds how much the *host* injects ahead of what it has +seen the guest report; it does not bound how long a starved host leaves that +packet queued before the guest's vCPU runs again to drain it. The keyboard path +closes that gap by pacing against the guest's own echo of each burst +(`shell_type_line`); the mouse path paces against a running count of arrived +events, which is a weaker guarantee against the same starvation this file's +keyboard entries already document. This run's own log shows the host holding +at least three other worktrees' builds at the moment `i8042_mouse` failed +(`[host-builds] … all 4 held by 4 holder(s)`), which is the condition that +mechanism needs. + +**Not established as the mechanism** — nothing in this capture names which +byte QEMU dropped or when, and the 2026-08-07 sighting's own A/B (this file's +catalog) left open whether that one was a loss or a miscount. It is offered as +the plausible, code-grounded reading and nothing stronger. + +## Exit condition + +The mechanism named with evidence (which byte, when, under what host +condition) rather than inferred from a doc comment written for a different +device queue, fixed — most likely by pacing the mouse injection against the +guest's own report the way `shell_type_line` already paces the keyboard's — and +a test that turns red on it deterministically. Then this row, its +`src/redlist.rs` entry, and the corresponding bullet in +`issues/build/parallel-tests-red-under-other-suites.md` are removed. + +## Owner + +The i8042/input path. diff --git a/src/lib.rs b/src/lib.rs index 2ef6e4d30d..dcba3c9dbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod pr; pub mod redlist; pub mod release; pub mod sdkversion; +pub mod socketpath; pub mod soundfont; /// Nothing outside its own gates reads this, so it is not compiled into the /// build system at all. diff --git a/src/redlist.rs b/src/redlist.rs index e5d77595d6..fcb0ecf774 100644 --- a/src/redlist.rs +++ b/src/redlist.rs @@ -41,6 +41,10 @@ pub const DISABLED: &[Disabled] = &[ }, Disabled { test: "handle_transfer", issue: "issues/kernel/deferred-release-outlives-its-syscall.md" }, Disabled { test: "hda_tone", issue: "issues/audio/hda-tone-phase-check.md" }, + Disabled { + test: "i8042_mouse", + issue: "issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md", + }, Disabled { test: "kill_while_blocked", issue: "issues/kernel/deferred-release-outlives-its-syscall.md" }, Disabled { test: "latency_wake", issue: "issues/build/latency-wake-reds-on-the-dev-host-at-a-rate.md" }, Disabled { diff --git a/src/socketpath.rs b/src/socketpath.rs new file mode 100644 index 0000000000..92c0bb4527 --- /dev/null +++ b/src/socketpath.rs @@ -0,0 +1,58 @@ +//! A Unix-domain socket path short enough for any `$TMPDIR` a host might hand +//! out. +//! +//! Darwin's `sockaddr_un.sun_path` holds 104 bytes, NUL included — Linux's +//! holds 108 — and a macOS `$TMPDIR` +//! (`/private/var/folders/<2>/<27ish random>/T`) plus this project's own lane +//! path (`toyos-tmp--/tests-/lane-/`) can already spend every one +//! of those 104 bytes before a filename is added: seen on `lan_mdns_answer`, +//! `connect to QEMU's /private/var/folders/.../T/toyos-tmp-55923-0/tests-0/lane-2/tap-out-0.sock: +//! path must be shorter than SUN_LEN`. [`short`] sits under `/tmp` directly — +//! not `$TMPDIR`, whose canonicalized macOS form is what grew that deep — so a +//! caller never inherits the host's own `$TMPDIR` depth. + +use std::path::PathBuf; + +/// Darwin's `sockaddr_un.sun_path`, the tighter of the two platforms this +/// project runs guests on; a path under this fits Linux's 108-byte one too. +const DARWIN_SUN_PATH: usize = 104; + +/// A path for a Unix-domain socket named `label`, this process's `n`th one. +/// Short on every host: fixed at `/tmp`, never `$TMPDIR`. +pub fn short(label: &str, n: u32) -> PathBuf { + let path = PathBuf::from(format!("/tmp/toyos-{label}-{}-{n}.sock", std::process::id())); + assert!( + path.as_os_str().len() < DARWIN_SUN_PATH, + "{path:?} does not fit a {DARWIN_SUN_PATH}-byte sockaddr_un.sun_path" + ); + path +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The exact shape the failure was seen under: a macOS `$TMPDIR` plus this + /// project's lane path already fills every byte `sockaddr_un.sun_path` + /// gives it, with nothing left for a filename — the old construction this + /// module replaces. + #[test] + fn the_lane_path_a_typical_macos_tmpdir_produced_did_not_fit() { + let tmpdir = "/private/var/folders/gr/mr4_fg4n34jb417sx1g5cgxc0000gp/T"; + let old = format!("{tmpdir}/toyos-tmp-55923-0/tests-0/lane-2/tap-out-0.sock"); + assert!( + old.len() >= DARWIN_SUN_PATH, + "{old:?} ({} bytes) was expected to no longer fit, and does", + old.len() + ); + } + + /// [`short`] never depends on `$TMPDIR`, so a six-digit pid and a sequence + /// number both past anything this harness has produced yet still fit. + #[test] + fn a_short_path_fits_regardless_of_the_hosts_tmpdir() { + let path = short("tap-out", 999_999); + assert!(path.as_os_str().len() < DARWIN_SUN_PATH, "{path:?}"); + assert!(path.starts_with("/tmp"), "{path:?}"); + } +} diff --git a/tests/common/segment.rs b/tests/common/segment.rs index 64f90d0330..9576e484bd 100644 --- a/tests/common/segment.rs +++ b/tests/common/segment.rs @@ -28,14 +28,17 @@ pub struct Tap { } impl Tap { - /// Two socket paths of this boot's own, in this thread's scratch directory. + /// Two socket paths of this boot's own. **Not the lane directory**: a + /// macOS `$TMPDIR` plus this project's own lane path can already leave + /// nothing of Darwin's 104-byte `sockaddr_un.sun_path` for a filename + /// (`toyos_build::socketpath`), so these sit under `/tmp` directly, named + /// for this process and unique per tap. pub fn in_lane() -> Self { static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); - let dir = super::lane::dir(); let tap = Self { - into_guest: dir.join(format!("tap-in-{n}.sock")), - from_guest: dir.join(format!("tap-out-{n}.sock")), + into_guest: toyos_build::socketpath::short("tap-in", n), + from_guest: toyos_build::socketpath::short("tap-out", n), }; let _ = std::fs::remove_file(&tap.into_guest); let _ = std::fs::remove_file(&tap.from_guest); @@ -67,6 +70,10 @@ impl Tap { }; let into = connect(&self.into_guest)?; let mut from = connect(&self.from_guest)?; + // Both ends are open, and nothing else will ever connect to these + // names: `/tmp` is not this run's lane, so nothing else sweeps them. + let _ = std::fs::remove_file(&self.into_guest); + let _ = std::fs::remove_file(&self.from_guest); let (tx, frames) = mpsc::channel(); std::thread::spawn(move || { let mut len = [0u8; 4]; From 2136d32ef83762146cbe3eefff4e0dc894c812da Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 27 Sep 2026 23:56:54 +0200 Subject: [PATCH 3/8] Every harness socket is a /tmp name its boot holds; the i8042 issue says what is known The QMP socket was still `lane::dir()/qmp-{seq}.sock`, the same `$TMPDIR`-depth path the tap moved off: 100 bytes at lane-2/seq 0 on the dev host, and `lane-10/qmp-1000.sock` reaches the 104 Darwin's connect refuses. It now comes from `socketpath::short("qmp", seq)`, as both tap sockets do. `socketpath::short` returns a `Socket` that removes its name when dropped. The `QemuInstance` holds the QMP socket and the `Tap`, so both names go once QEMU is reaped. They also go on every early return and every unwind out of the boot, including `mdns()` returning at `await_marker` before it ever opened the tap. The unlink in `Tap::open` and the QMP `remove_file` in `drop` are gone. `BootOptions::segment` is now a flag, and the test opens the segment through `QemuInstance::segment`. `Tap::in_lane` becomes `Tap::of_boot(seq)`, keyed to the boot's own sequence number. `profile_argv` builds the tap it is asked for instead of dropping it. The host test that asserted a literal's length is deleted. The remaining tests pin that `short` lives under `/tmp` and fits, and that a dropped `Socket` removes its name. The i8042 issue's starvation mechanism is refuted: the mouse already paces against the guest's own report. The file is renamed for what was measured: 872 of 876 in 17 s, a clean end, and a shortfall equal to `MOUSE_LEAD`. It names the two paths the tree offers. One is a >5 ms decoder gap inside a -1 packet producing a right-button press and release, measured on the host through `MouseDecoder`. The other is a non-zero exit this test never reads. Neither is established, so the file says "mechanism not known". The partition-claim issue loses its false "beside other guests" and "23 other parallel tasks" claims (the job ran one wide). It also loses the unsupported lost-output reading. Its exit now requires `guest_verdict`'s exit-0 refusal to carry the kernel window. This branch's edit to `parallel-tests-red-under-other-suites.md` is reverted to main's text. Co-Authored-By: Claude Opus 5.5 --- ...ts-clean-with-none-of-its-refusals-said.md | 26 +++-- .../parallel-tests-red-under-other-suites.md | 23 ----- ...ds-four-packets-short-with-a-clean-exit.md | 62 ++++++++++++ ...se-loses-a-packet-under-host-contention.md | 68 ------------- src/qemu.rs | 7 +- src/redlist.rs | 2 +- src/socketpath.rs | 97 ++++++++++--------- tests/common/lane.rs | 2 +- tests/common/origin.rs | 5 +- tests/common/qemu.rs | 55 +++++++---- tests/common/segment.rs | 46 +++------ 11 files changed, 184 insertions(+), 209 deletions(-) create mode 100644 issues/hardware/i8042-mouse-ends-four-packets-short-with-a-clean-exit.md delete mode 100644 issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md diff --git a/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md b/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md index 78d1a514e3..8fcf22bf09 100644 --- a/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md +++ b/issues/boot-media/partition-claim-departure-exits-clean-with-none-of-its-refusals-said.md @@ -4,7 +4,7 @@ kind: tooling opened: 2026-09-27 --- -# `partition_claim_departure` exits 0 having said none of its refusals, beside other guests, green alone +# `partition_claim_departure` exits 0 having said none of its refusals Seen on 2026-09-27 in the nightly run 36336701867, job `guest (5)`, on PR #541's head `1c0f0c753fb2c4c2d01caf51dc67b7f92ca4cd8e` — a diff that touches @@ -46,22 +46,17 @@ or `"partition_claimant: PASS"`), yet the guest's exit code was 0. An exit of 0 rules out a panic on a wrong assertion (`departure()`'s `assert_eq!`s all `panic!` on mismatch, and a panic does not exit 0), so the -guest's own logic is not shown to have run into an unexpected state — the -narrower reading, offered without more evidence, is that this run lost the -guest's captured output rather than that the guest produced none. `departed()` -also depends on a QMP hook (`MOVE_NOW`) firing off a marker the guest prints -mid-run, over a channel the CI shard shares with 23 other parallel tasks on a -4-core runner (`shard 5/12: 24 parallel task(s), 1 serial`); this suite's own -`tests/CLAUDE.md` already names both a channel that can silently lose a +guest's own logic is not shown to have run into an unexpected state. +`departed()` also depends on a QMP hook (`MOVE_NOW`) firing off a marker the +guest prints mid-run; this suite's own `tests/CLAUDE.md` already names both a channel that can silently lose a stimulus and a demultiplexer for concurrent guests' console lines as classes of defect this harness is exposed to, and either is consistent with what was seen. Nothing here narrows which. This is the same family flagged in `issues/boot-media/partition-claim-gives-up-reds-beside-other-guests-and-is-green-alone.md` -— same test area, same "reds beside other guests, green alone, no rate" -shape, and that file already widens its scope to `partition_claim_departure`. -It is filed separately rather than folded in because the failure signature +— same test area, and that file already widens its scope to +`partition_claim_departure`. It is filed separately rather than folded in because the failure signature differs: that file's evidence is a kernel-log line count coming up short (`"{count} flushes were told of the loss, not {told}"`, matched against lines the kernel actually printed) on the `silent` role, attributed to a @@ -73,10 +68,11 @@ is unconfirmed. ## Exit condition -The mechanism — lost guest output under this shard's concurrency, a QMP hook -racing the marker it is keyed on, or something else — named and fixed, and a -test that turns red on it deterministically (not "green alone, red beside -other guests"). Then this row and its `src/redlist.rs` entry are deleted. +`guest_verdict`'s "exited 0 having said" refusal (`tests/common/partclaim.rs`) +carries the kernel window, as its non-zero-exit refusal already does, so the +next sighting is not blind; the mechanism — a QMP hook racing the marker it is +keyed on, or something else — named and fixed; and a test that turns red on it +deterministically. Then this row and its `src/redlist.rs` entry are deleted. ## Owner diff --git a/issues/build/parallel-tests-red-under-other-suites.md b/issues/build/parallel-tests-red-under-other-suites.md index 30407759ac..04047234b5 100644 --- a/issues/build/parallel-tests-red-under-other-suites.md +++ b/issues/build/parallel-tests-red-under-other-suites.md @@ -50,29 +50,6 @@ changes. re-run. So it is not a tree difference and it is not gone — one packet in a thousand is still being lost, or still being counted wrong, under a host carrying two suites. - **A third sighting, 2026-09-27**, the orchestrator's Fast-tier run on PR - #537's head `06b926b1` (a diff that renames paths only and touches neither - the i8042 driver nor this test): `872 pointer events reached userland out of - 876 packets injected, never more than 4 of them (12 bytes) outstanding - against a 16-byte device queue` — the identical shape and the identical - bound, four packets short this time rather than one. `cargo run -- - --known-red i8042_mouse` answered NO. The host was carrying at least three - other worktrees' builds at the moment it fired - (`[host-builds] … all 4 held by 4 holder(s)`), consistent with this file's - keyboard-side finding elsewhere in this tree (`tests/common/qemu.rs`'s - `QmpInput::type_burst`): QEMU's PS/2 queue — the same `QEMU_PS2_QUEUE` - constant the mouse pacing budgets against — drops what a guest whose vCPU the - host has not run for a couple hundred milliseconds has not drained, silently - and one byte at a time. `MOUSE_LEAD` bounds what the *host* holds - outstanding; nothing bounds how long a starved host leaves it there before - the guest is scheduled again, which is a gap the keyboard's own bound does - not close either — it is closed by pacing against the guest's echo, which - the mouse path does not do. **Not established as this mechanism**: it - explains a loss under host contention without contradicting anything in the - capture, but nothing here identifies which byte QEMU actually dropped or - when. Disabled for this reason in `src/redlist.rs` - (`issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md`) - rather than re-run away a third time. - **`i8042_absent`** — same session, same shape, and it is `Sched::Serial` already, so intra-suite width is not what reaches it. The verdict is the guest's own `Boot: complete` on two boots with a 300 ms allowance; the landing diff --git a/issues/hardware/i8042-mouse-ends-four-packets-short-with-a-clean-exit.md b/issues/hardware/i8042-mouse-ends-four-packets-short-with-a-clean-exit.md new file mode 100644 index 0000000000..fbf7c7a476 --- /dev/null +++ b/issues/hardware/i8042-mouse-ends-four-packets-short-with-a-clean-exit.md @@ -0,0 +1,62 @@ +--- +status: expected-red +kind: tooling +opened: 2026-09-27 +--- + +# `i8042_mouse` ends four packets short with a clean exit; mechanism not known + +Seen on 2026-09-27 in the orchestrator's Fast-tier run on PR #537's head +`06b926b1`, a diff that touches neither the i8042 driver nor this test: + +``` +FAIL i8042_mouse: 872 pointer events reached userland out of 876 packets injected, never more than 4 of them (12 bytes) outstanding against a 16-byte device queue + FAIL i8042_mouse (17s) +``` + +`cargo run -- --known-red i8042_mouse` answered NO. Earlier sightings of this +message are in `issues/build/parallel-tests-red-under-other-suites.md`'s +`i8042_mouse` entry. + +## What is known + +- **The run ended cleanly.** This message is reached only when + `run_test_paced` returned no error, so the test runner printed + `===TEST_END test_rs_i8042_mouse exit====`; a stall, a ceiling or a + runner error ends in the `STALLED` message instead. +- **The guest stopped reading mid-burst.** 876 injected is the four lead-in + packets plus 872 of `BURST`'s 1000. The shortfall, 4, equals `MOUSE_LEAD`: + the host always refills to `arrived + MOUSE_LEAD`, so any guest that stops + reading mid-burst leaves exactly that many unread. +- **Not the guest's `RUN_CEILING`.** The test took 17 s, and the ceiling is + 60 s from `===I8042_MOUSE_READY===`. +- **The capture that would tell is not kept.** The message carries neither + the guest's stdout, the kernel's serial window, nor the exit code, and + `i8042_mouse` never reads `exit_code` before this count. So the log's missing + `mev done` line says nothing. The boot is `Profile::Metal`, whose 16550 is + the console on stdio, so it writes no `uart-*.log`. No `Boot parameter:` line + in the run's kept serial logs carries `i8042-trace`. + +Two paths in the tree end the guest this way, and nothing captured tells +them apart: + +- **The guest's own end rule, met by a misframed packet.** + `tests/toyos-rust-tests/src/bin/i8042_mouse.rs` exits 0 on the first event + without the right button after one with it. `toyos_ps2::mouse`'s decoder + resets to a head on any gap between bytes longer than `PACKET_GAP_NS` (5 ms). + Measured on the host by feeding the burst's bytes to `MouseDecoder`: one such + gap between a −1 packet's head `0x18` and its `dx` `0xFF` takes `0xFF` as a + head. The decoder emits `buttons=0x07`, discards the next packet's `0x01 0x00`, + and then emits the following −1 with `buttons=0x00`. That is a press and + release of the right button. Whether a gap that long in guest time lands + inside a packet on this host is not measured. +- **A non-zero exit**, which this test does not read. + +## Exit condition + +The mechanism is named, and a deterministic test is red on it. Then this file +and its `src/redlist.rs` row are deleted. + +## Owner + +The i8042/input path, held by the orchestrator. diff --git a/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md b/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md deleted file mode 100644 index ee1986d7e3..0000000000 --- a/issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -status: expected-red -kind: tooling -opened: 2026-09-27 ---- - -# `i8042_mouse` loses a packet under host contention, three sightings now - -Seen on 2026-09-27 in the orchestrator's Fast-tier run on PR #537's head -`06b926b1` — a diff that renames paths only and touches neither the i8042 -driver nor this test: - -``` -FAIL i8042_mouse: 872 pointer events reached userland out of 876 packets -injected, never more than 4 of them (12 bytes) outstanding against a 16-byte -device queue -``` - -`cargo run -- --known-red i8042_mouse` answered NO. This is the third recorded -sighting of the identical shape and bound (the first two, 2026-08-06/07, are -`issues/build/parallel-tests-red-under-other-suites.md`'s `i8042_mouse` entry, -which this file extends rather than duplicates — read it for the full -history, including the fix that closed the first, different mechanism: a -pacing lead wide enough to make QEMU sum motion it had no room to queue). - -## What is known - -The test's own design (`tests/toyos.rs`'s `i8042_mouse`) paces its injection so -the host never holds more than `MOUSE_LEAD` (4 packets, 12 of the device's 16 -bytes) outstanding, on the stated premise that staying inside what the device -holds should leave no loss to explain away. That bound was already the fix for -the first two sightings' mechanism, and this loss is inside it, so it is not a -recurrence of that one. - -`tests/common/qemu.rs`'s own doc comment on `QmpInput::type_burst`, written for -the keyboard path and citing the same `QEMU_PS2_QUEUE` constant this test -budgets against, names a mechanism this test does not guard against: "a guest -whose vCPU the host has not run for a couple hundred milliseconds drains none -of them — at which point the queue starts dropping, silently and one byte at a -time." `MOUSE_LEAD` bounds how much the *host* injects ahead of what it has -seen the guest report; it does not bound how long a starved host leaves that -packet queued before the guest's vCPU runs again to drain it. The keyboard path -closes that gap by pacing against the guest's own echo of each burst -(`shell_type_line`); the mouse path paces against a running count of arrived -events, which is a weaker guarantee against the same starvation this file's -keyboard entries already document. This run's own log shows the host holding -at least three other worktrees' builds at the moment `i8042_mouse` failed -(`[host-builds] … all 4 held by 4 holder(s)`), which is the condition that -mechanism needs. - -**Not established as the mechanism** — nothing in this capture names which -byte QEMU dropped or when, and the 2026-08-07 sighting's own A/B (this file's -catalog) left open whether that one was a loss or a miscount. It is offered as -the plausible, code-grounded reading and nothing stronger. - -## Exit condition - -The mechanism named with evidence (which byte, when, under what host -condition) rather than inferred from a doc comment written for a different -device queue, fixed — most likely by pacing the mouse injection against the -guest's own report the way `shell_type_line` already paces the keyboard's — and -a test that turns red on it deterministically. Then this row, its -`src/redlist.rs` entry, and the corresponding bullet in -`issues/build/parallel-tests-red-under-other-suites.md` are removed. - -## Owner - -The i8042/input path. diff --git a/src/qemu.rs b/src/qemu.rs index b40eb51d86..f927dea4fe 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -25,10 +25,9 @@ //! //! # QMP, and the machine that has already stopped //! -//! The socket is `/tmp/toyos-qmp.sock`. A harness test booted with -//! `BootOptions { qmp: true }` leaves one under -//! the run's lane directory, `$TMPDIR/toyos-tmp--*/tests-*/lane-/` -//! while the run lives (`tests/common/lane.rs`), which is how a frozen guest is read +//! The socket is `/tmp/toyos-qmp.sock`, and a harness boot's is +//! `/tmp/toyos-qmp--.sock` ([`crate::socketpath::short`]) while its +//! guest lives, which is how a frozen guest is read //! without a `cargo run` at all: `human-monitor-command` with `info registers //! -a` gives every vCPU's `RIP`, `RFL` and `HLT`, and that is what tells a //! halted-awaiting-interrupt machine from a wedged one. diff --git a/src/redlist.rs b/src/redlist.rs index fcb0ecf774..9adcffd4c9 100644 --- a/src/redlist.rs +++ b/src/redlist.rs @@ -43,7 +43,7 @@ pub const DISABLED: &[Disabled] = &[ Disabled { test: "hda_tone", issue: "issues/audio/hda-tone-phase-check.md" }, Disabled { test: "i8042_mouse", - issue: "issues/hardware/i8042-mouse-loses-a-packet-under-host-contention.md", + issue: "issues/hardware/i8042-mouse-ends-four-packets-short-with-a-clean-exit.md", }, Disabled { test: "kill_while_blocked", issue: "issues/kernel/deferred-release-outlives-its-syscall.md" }, Disabled { test: "latency_wake", issue: "issues/build/latency-wake-reds-on-the-dev-host-at-a-rate.md" }, diff --git a/src/socketpath.rs b/src/socketpath.rs index 92c0bb4527..f7bc947477 100644 --- a/src/socketpath.rs +++ b/src/socketpath.rs @@ -1,58 +1,67 @@ -//! A Unix-domain socket path short enough for any `$TMPDIR` a host might hand -//! out. -//! -//! Darwin's `sockaddr_un.sun_path` holds 104 bytes, NUL included — Linux's -//! holds 108 — and a macOS `$TMPDIR` -//! (`/private/var/folders/<2>/<27ish random>/T`) plus this project's own lane -//! path (`toyos-tmp--/tests-/lane-/`) can already spend every one -//! of those 104 bytes before a filename is added: seen on `lan_mdns_answer`, -//! `connect to QEMU's /private/var/folders/.../T/toyos-tmp-55923-0/tests-0/lane-2/tap-out-0.sock: -//! path must be shorter than SUN_LEN`. [`short`] sits under `/tmp` directly — -//! not `$TMPDIR`, whose canonicalized macOS form is what grew that deep — so a -//! caller never inherits the host's own `$TMPDIR` depth. - -use std::path::PathBuf; - -/// Darwin's `sockaddr_un.sun_path`, the tighter of the two platforms this -/// project runs guests on; a path under this fits Linux's 108-byte one too. -const DARWIN_SUN_PATH: usize = 104; - -/// A path for a Unix-domain socket named `label`, this process's `n`th one. -/// Short on every host: fixed at `/tmp`, never `$TMPDIR`. -pub fn short(label: &str, n: u32) -> PathBuf { +//! A Unix-domain socket's name that fits `sockaddr_un.sun_path` on every host, +//! and is gone when its holder is. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +/// A socket name under `/tmp`, removed when this is dropped: on a return and on +/// a panic's unwind alike. +#[derive(Debug)] +pub struct Socket(PathBuf); + +impl Socket { + pub fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Socket { + fn drop(&mut self) { + match std::fs::remove_file(&self.0) { + Ok(()) => {} + // Whoever was to bind it never ran, or unlinked it on its way out. + Err(e) if e.kind() == ErrorKind::NotFound => {} + // A second panic here would abort and lose the first one's message. + Err(e) if std::thread::panicking() => eprintln!("remove {}: {e}", self.0.display()), + Err(e) => panic!("remove {}: {e}", self.0.display()), + } + } +} + +/// This process's `n`th socket named `label`. Under `/tmp` and never `$TMPDIR`, +/// whose depth is the host's to choose. +pub fn short(label: &str, n: u32) -> Socket { let path = PathBuf::from(format!("/tmp/toyos-{label}-{}-{n}.sock", std::process::id())); - assert!( - path.as_os_str().len() < DARWIN_SUN_PATH, - "{path:?} does not fit a {DARWIN_SUN_PATH}-byte sockaddr_un.sun_path" - ); - path + // A run killed before its drop left this name, under a pid now reused. + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => panic!("remove the stale {}: {e}", path.display()), + } + Socket(path) } #[cfg(test)] mod tests { use super::*; - /// The exact shape the failure was seen under: a macOS `$TMPDIR` plus this - /// project's lane path already fills every byte `sockaddr_un.sun_path` - /// gives it, with nothing left for a filename — the old construction this - /// module replaces. - #[test] - fn the_lane_path_a_typical_macos_tmpdir_produced_did_not_fit() { - let tmpdir = "/private/var/folders/gr/mr4_fg4n34jb417sx1g5cgxc0000gp/T"; - let old = format!("{tmpdir}/toyos-tmp-55923-0/tests-0/lane-2/tap-out-0.sock"); - assert!( - old.len() >= DARWIN_SUN_PATH, - "{old:?} ({} bytes) was expected to no longer fit, and does", - old.len() - ); - } + /// Darwin's `sockaddr_un.sun_path`, NUL included; Linux's is 108. + const DARWIN_SUN_PATH: usize = 104; - /// [`short`] never depends on `$TMPDIR`, so a six-digit pid and a sequence - /// number both past anything this harness has produced yet still fit. #[test] fn a_short_path_fits_regardless_of_the_hosts_tmpdir() { - let path = short("tap-out", 999_999); + let socket = short("tap-out", u32::MAX); + let path = socket.path(); assert!(path.as_os_str().len() < DARWIN_SUN_PATH, "{path:?}"); assert!(path.starts_with("/tmp"), "{path:?}"); } + + #[test] + fn a_dropped_socket_takes_its_name_with_it() { + let socket = short("drop", u32::MAX); + let path = socket.path().to_path_buf(); + std::fs::write(&path, b"").expect("stand in for the bound socket"); + drop(socket); + assert!(!path.exists(), "{path:?} outlived its holder"); + } } diff --git a/tests/common/lane.rs b/tests/common/lane.rs index 9046d26985..fbe4a98240 100644 --- a/tests/common/lane.rs +++ b/tests/common/lane.rs @@ -62,7 +62,7 @@ pub fn dir() -> PathBuf { static RUN: OnceLock = OnceLock::new(); /// This run's hold on its scratch directory, from before the first boot to the -/// exit: every image, boot log, screendump and socket of every lane is under +/// exit: every image, boot log and screendump of every lane is under /// it, and it is gone when the run is, green or red (`toyos_tmpdir` is the /// policy, and what reclaims the directory of a run that was killed). /// diff --git a/tests/common/origin.rs b/tests/common/origin.rs index 2a3cd88b13..3b3df827d5 100644 --- a/tests/common/origin.rs +++ b/tests/common/origin.rs @@ -647,13 +647,12 @@ pub fn carrier_forgery(c_bins: &[(String, Vec)], rust_bins: &[(String, Vec)], rust_bins: &[(String, Vec)]) -> Result<(), String> { - let tap = segment::Tap::in_lane(); - let options = BootOptions { profile: VIRTIO.profile, segment: Some(tap.clone()), ..Default::default() }; + let options = BootOptions { profile: VIRTIO.profile, segment: true, ..Default::default() }; let config = compile::repo_root().join(VIRTIO.config); let mut guest = QemuInstance::boot_with_options(&config, c_bins, rust_bins, options); let mut console = guest.boot_log().to_string(); qemu::await_marker(&mut guest, &mut console, "netd: DHCP: lease ", "netd's lease")?; - let mut wire = tap.open()?; + let mut wire = guest.segment()?; let deadline = || Instant::now() + Duration::from_secs(10); wire.send(&segment::arp_request(NEIGHBOUR_MAC, NEIGHBOUR, GUEST))?; diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index ed205a5807..e8a00e4068 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -10,6 +10,7 @@ use std::{fs, thread}; use super::compile; use toyos_build::arch::{Accel, Arch}; +use toyos_build::socketpath::{self, Socket}; /// The architecture every machine this suite builds and boots is: the suite's /// q35 shapes, i8042 and VT-d are x86-64's, and the aarch64 bring-up boots @@ -2433,8 +2434,9 @@ pub struct BootOptions { pub console_file: bool, /// Put the host on the guest's own segment (`super::segment`): frames /// it writes reach the NIC as if off the cable, and it sees every frame the - /// guest sends. Refused by name on a profile with no NIC. - pub segment: Option, + /// guest sends, through [`QemuInstance::segment`]. Refused by name on a + /// profile with no NIC. + pub segment: bool, /// Forward this host port to the guest's TCP 22. **slirp is one-way /// without it**: nothing on the host can open a connection into the guest /// unless QEMU is told which port to translate. A profile with no NIC @@ -2535,7 +2537,7 @@ impl Default for BootOptions { extra_root_files: Vec::new(), log_port: None, console_file: false, - segment: None, + segment: false, ssh_port: None, wire_dump: None, userland_nvme: None, @@ -2645,7 +2647,8 @@ pub struct QemuInstance { uart_log: PathBuf, nvme: NvmeClaim, usb_images: Vec, - qmp_socket: Option, + qmp_socket: Option, + segment: Option, screendump: PathBuf, /// The image this boot built for itself, which is the only one it may /// delete: a [`BootOptions::boot_image`] belongs to the test that staged it @@ -3154,10 +3157,8 @@ impl QemuInstance { let audio_wav = test_dir.join(format!("audio-{seq}.wav")); let _ = fs::remove_file(&audio_wav); - let qmp_socket = options.qmp.then(|| test_dir.join(format!("qmp-{seq}.sock"))); - if let Some(path) = &qmp_socket { - let _ = fs::remove_file(path); - } + let qmp_socket = options.qmp.then(|| socketpath::short("qmp", seq)); + let segment = options.segment.then(|| super::segment::Tap::of_boot(seq)); let screendump = test_dir.join(format!("screen-{seq}.ppm")); // Per-instance, not a fixed /tmp path: the audio gate boots dozens of @@ -3173,7 +3174,8 @@ impl QemuInstance { &usb_images, &audio_wav, &uart_log, - qmp_socket.as_deref(), + qmp_socket.as_ref().map(Socket::path), + segment.as_ref(), &options, ); spawn_and_wait_ready( @@ -3186,6 +3188,7 @@ impl QemuInstance { nvme, usb_images, qmp_socket, + segment, screendump, own_boot_image, carried, @@ -3202,7 +3205,11 @@ impl QemuInstance { /// command that answers, so what a test judges is memory QEMU dumped and not /// a report the guest wrote about itself. pub fn guest_memory(&mut self, phys: u64, bytes: usize) -> Result, String> { - let socket = self.qmp_socket.clone().expect("guest_memory needs BootOptions { qmp: true }"); + let socket = self + .qmp_socket + .as_ref() + .map(|s| s.path().to_path_buf()) + .expect("guest_memory needs BootOptions { qmp: true }"); // Beside the screendump, which is this instance's own scratch path. let out = self.screendump.with_extension(format!("mem-{phys:#x}")); let _ = fs::remove_file(&out); @@ -3231,7 +3238,8 @@ impl QemuInstance { pub fn screendump(&mut self) -> super::screen::Ppm { let socket = self .qmp_socket - .clone() + .as_ref() + .map(|s| s.path().to_path_buf()) .expect("screendump needs BootOptions { qmp: true }"); let out = self.screendump.clone(); let _ = fs::remove_file(&out); @@ -3547,7 +3555,12 @@ impl QemuInstance { /// The QMP socket this instance opened. Injection needs it, and it needs /// `BootOptions { qmp: true }`. pub fn qmp_socket(&self) -> &Path { - self.qmp_socket.as_ref().expect("qmp_socket needs BootOptions { qmp: true }") + self.qmp_socket.as_ref().map(Socket::path).expect("qmp_socket needs BootOptions { qmp: true }") + } + + /// Stand on this guest's segment; it needs `BootOptions { segment: true }`. + pub fn segment(&self) -> Result { + self.segment.as_ref().expect("segment needs BootOptions { segment: true }").open() } /// [`budget`] for a host-side wait on *this* guest, widened by the guest's @@ -3608,7 +3621,7 @@ impl QemuInstance { self.stdin.flush().expect("Failed to flush QEMU stdin"); let mut fire = - |line: &str, socket: Option<&PathBuf>| step(socket.map(PathBuf::as_path), line); + |line: &str, socket: Option<&Socket>| step(socket.map(Socket::path), line); // `run [args...]`, and the markers carry only the binary name. let want = name.split_whitespace().next().unwrap_or(name); @@ -3816,14 +3829,12 @@ impl Drop for QemuInstance { // reads as "there was nothing to keep" rather than "it was deleted // before the step ran". let _ = fs::remove_file(&self.screendump); - if let Some(socket) = &self.qmp_socket { - let _ = fs::remove_file(socket); - } // A per-boot image is hundreds of megabytes and a full run makes ~76 of // them; the shared name used to make that one file. if let Some(image) = &self.own_boot_image { let _ = fs::remove_file(image); } + // The QMP and tap sockets' names go with their fields, after QEMU is reaped. LIVE.fetch_sub(1, Ordering::SeqCst); } } @@ -4329,7 +4340,9 @@ impl QmpDevices { pub fn profile_argv(options: &BootOptions) -> Vec { let p = Path::new("/nonexistent"); let usb: Vec = options.profile.usb_disks().iter().map(|_| p.to_path_buf()).collect(); - qemu_command(p, p, &usb, p, p, None, options) + // A sequence no boot reaches, so no live boot's names are touched. + let segment = options.segment.then(|| super::segment::Tap::of_boot(u32::MAX)); + qemu_command(p, p, &usb, p, p, None, segment.as_ref(), options) .get_args() .map(|a| a.to_string_lossy().into_owned()) .collect() @@ -4356,6 +4369,7 @@ fn qemu_command( audio_wav: &Path, uart_log: &Path, qmp_socket: Option<&Path>, + segment: Option<&super::segment::Tap>, options: &BootOptions, ) -> Command { let shape = options.profile.shape(); @@ -4735,7 +4749,7 @@ fn qemu_command( qemu.arg("-object") .arg(format!("filter-dump,id=wire,netdev=net0,file={}", at.display())); } - if let Some(tap) = &options.segment { + if let Some(tap) = segment { assert!( !matches!(shape.nic, Nic::Absent), "this profile carries no NIC, so there is no `net0` segment to stand on" @@ -4805,7 +4819,8 @@ struct Files { uart_log: PathBuf, nvme: NvmeClaim, usb_images: Vec, - qmp_socket: Option, + qmp_socket: Option, + segment: Option, screendump: PathBuf, own_boot_image: Option, carried: Option>, @@ -4880,6 +4895,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) nvme, usb_images, qmp_socket, + segment, screendump, own_boot_image, carried, @@ -4980,6 +4996,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) nvme, usb_images, qmp_socket, + segment, screendump, own_boot_image, boot_log, diff --git a/tests/common/segment.rs b/tests/common/segment.rs index 9576e484bd..b09084f17a 100644 --- a/tests/common/segment.rs +++ b/tests/common/segment.rs @@ -12,37 +12,25 @@ use std::io::{Read, Write}; use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::path::Path; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::time::Instant; use toyos_build::icmp::checksum; +use toyos_build::socketpath::{self, Socket}; -/// The two sockets QEMU serves the segment on, which [`super::qemu::BootOptions`] -/// carries into the argv. -#[derive(Clone, Debug)] +/// The two sockets QEMU serves the segment on, held by the +/// [`super::qemu::QemuInstance`] that booted with them. +#[derive(Debug)] pub struct Tap { - into_guest: PathBuf, - from_guest: PathBuf, + into_guest: Socket, + from_guest: Socket, } impl Tap { - /// Two socket paths of this boot's own. **Not the lane directory**: a - /// macOS `$TMPDIR` plus this project's own lane path can already leave - /// nothing of Darwin's 104-byte `sockaddr_un.sun_path` for a filename - /// (`toyos_build::socketpath`), so these sit under `/tmp` directly, named - /// for this process and unique per tap. - pub fn in_lane() -> Self { - static SEQ: AtomicU32 = AtomicU32::new(0); - let n = SEQ.fetch_add(1, Ordering::Relaxed); - let tap = Self { - into_guest: toyos_build::socketpath::short("tap-in", n), - from_guest: toyos_build::socketpath::short("tap-out", n), - }; - let _ = std::fs::remove_file(&tap.into_guest); - let _ = std::fs::remove_file(&tap.from_guest); - tap + /// The two socket names of boot `seq`. + pub fn of_boot(seq: u32) -> Self { + Self { into_guest: socketpath::short("tap-in", seq), from_guest: socketpath::short("tap-out", seq) } } /// QEMU's half: two listening sockets, one filter each, both on `net0`. A @@ -51,11 +39,11 @@ impl Tap { pub fn argv(&self) -> [String; 8] { [ "-chardev".into(), - format!("socket,id=tapin,path={},server=on,wait=off", self.into_guest.display()), + format!("socket,id=tapin,path={},server=on,wait=off", self.into_guest.path().display()), "-object".into(), "filter-redirector,id=tapinf,netdev=net0,queue=tx,indev=tapin".into(), "-chardev".into(), - format!("socket,id=tapout,path={},server=on,wait=off", self.from_guest.display()), + format!("socket,id=tapout,path={},server=on,wait=off", self.from_guest.path().display()), "-object".into(), "filter-mirror,id=tapoutf,netdev=net0,queue=rx,outdev=tapout".into(), ] @@ -65,15 +53,11 @@ impl Tap { /// sockets before the machine ran, so both connects answer at once; a frame /// the guest sent before them is not seen, and QEMU says so on its stderr. pub fn open(&self) -> Result { - let connect = |path: &PathBuf| { + let connect = |path: &Path| { UnixStream::connect(path).map_err(|e| format!("connect to QEMU's {}: {e}", path.display())) }; - let into = connect(&self.into_guest)?; - let mut from = connect(&self.from_guest)?; - // Both ends are open, and nothing else will ever connect to these - // names: `/tmp` is not this run's lane, so nothing else sweeps them. - let _ = std::fs::remove_file(&self.into_guest); - let _ = std::fs::remove_file(&self.from_guest); + let into = connect(self.into_guest.path())?; + let mut from = connect(self.from_guest.path())?; let (tx, frames) = mpsc::channel(); std::thread::spawn(move || { let mut len = [0u8; 4]; From 79955442067b658269154bc2117afbc2afe58d5e Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:01:16 +0200 Subject: [PATCH 4/8] A boot's sockets are one field; the SUN_LEN issues close with this fix `qemu_command` took eight arguments once the tap joined the QMP socket, which clippy refuses. `Sockets` holds both of a boot's `/tmp` names. `QemuInstance` drops it after QEMU is reaped, `qemu_command` reads it, and `profile_argv` builds one with the tap it is asked for and, as before, no QMP socket. `issues/build/a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md` and `...-outgrows-sun-len-on-the-dev-host.md` are this defect. Their exit is "fits sun_path on every host, lan_mdns_answer green on the dev host". The first half is `socketpath::short`. The second half is the orchestrator's run of `lan_mdns_answer` on this head. Co-Authored-By: Claude Opus 5.5 --- ...et-path-is-past-sun-len-on-the-dev-host.md | 27 ------- ...t-path-outgrows-sun-len-on-the-dev-host.md | 23 ------ tests/common/qemu.rs | 70 +++++++++++-------- 3 files changed, 39 insertions(+), 81 deletions(-) delete mode 100644 issues/build/a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md delete mode 100644 issues/build/a-lane-s-tap-socket-path-outgrows-sun-len-on-the-dev-host.md diff --git a/issues/build/a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md b/issues/build/a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md deleted file mode 100644 index a055bb032a..0000000000 --- a/issues/build/a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-26 ---- - -# A lane's tap socket path is past `SUN_LEN` on the dev host - -`lan_mdns_answer` reds on the macOS dev host, wide and alone: - -``` -connect to QEMU's /private/var/folders/gr/mr4_fg4n34jb417sx1g5cgxc0000gp/T/toyos-tmp-89085-0/tests-0/lane-3/tap-out-0.sock: path must be shorter than SUN_LEN -``` - -That path is 104 bytes, and macOS's `sun_path` holds 104 including the NUL. -`tests/common/segment.rs`'s `Tap::in_lane` puts both sockets in -`lane::dir()`, which since `toyos-tmpdir` is -`$TMPDIR/toyos-tmp--/tests-/lane-/`, and the dev host's -`$TMPDIR` resolves to 57 bytes (`/private/var/folders/…/T/`) before any of -that. A five-digit pid is enough to cross the limit. Seen on -`wt/toyos-layout` after it merged `origin/main` at `e48604c0`; nothing on that -branch touches the lane or the tap. - -## Exit condition - -A tap socket's path fits `sun_path` on every host the suite runs on, and -`lan_mdns_answer` is green on the dev host. diff --git a/issues/build/a-lane-s-tap-socket-path-outgrows-sun-len-on-the-dev-host.md b/issues/build/a-lane-s-tap-socket-path-outgrows-sun-len-on-the-dev-host.md deleted file mode 100644 index 230a3d13bb..0000000000 --- a/issues/build/a-lane-s-tap-socket-path-outgrows-sun-len-on-the-dev-host.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-26 ---- - -# A lane's tap socket path outgrows `SUN_LEN` on the dev host - -`lan_mdns_answer` reds wide and alone with `connect to QEMU's -/private/var/folders/gr/mr4_fg4n34jb417sx1g5cgxc0000gp/T/toyos-tmp-70685-0/tests-0/lane-7/tap-out-0.sock: -path must be shorter than SUN_LEN`. `common::segment::Tap::in_lane` puts the -two sockets in the lane's scratch directory, and on this macOS host that -directory sits under `$TMPDIR`, so the path is 104 bytes, past the 103 a -`sockaddr_un` holds before its terminating NUL on macOS. - -Seen in the fast tier twice in one session: at `origin/main` checked out in -the `toyos-guiplat` worktree (alone with this message, wide as `QEMU died -before ===READY===`), and at PR #528's head after it merged `e48604c0` (this -message wide and alone). `cargo run --- --known-red lan_mdns_answer` answers NO. - -**Exit**: the socket paths fit a `sockaddr_un` wherever the scratch -directory is, with `lan_mdns_answer` green on this host. diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index e8a00e4068..ab867bcc9f 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2647,8 +2647,7 @@ pub struct QemuInstance { uart_log: PathBuf, nvme: NvmeClaim, usb_images: Vec, - qmp_socket: Option, - segment: Option, + sockets: Sockets, screendump: PathBuf, /// The image this boot built for itself, which is the only one it may /// delete: a [`BootOptions::boot_image`] belongs to the test that staged it @@ -3157,8 +3156,10 @@ impl QemuInstance { let audio_wav = test_dir.join(format!("audio-{seq}.wav")); let _ = fs::remove_file(&audio_wav); - let qmp_socket = options.qmp.then(|| socketpath::short("qmp", seq)); - let segment = options.segment.then(|| super::segment::Tap::of_boot(seq)); + let sockets = Sockets { + qmp: options.qmp.then(|| socketpath::short("qmp", seq)), + segment: options.segment.then(|| super::segment::Tap::of_boot(seq)), + }; let screendump = test_dir.join(format!("screen-{seq}.ppm")); // Per-instance, not a fixed /tmp path: the audio gate boots dozens of @@ -3174,8 +3175,7 @@ impl QemuInstance { &usb_images, &audio_wav, &uart_log, - qmp_socket.as_ref().map(Socket::path), - segment.as_ref(), + &sockets, &options, ); spawn_and_wait_ready( @@ -3187,8 +3187,7 @@ impl QemuInstance { uart_log, nvme, usb_images, - qmp_socket, - segment, + sockets, screendump, own_boot_image, carried, @@ -3206,9 +3205,9 @@ impl QemuInstance { /// a report the guest wrote about itself. pub fn guest_memory(&mut self, phys: u64, bytes: usize) -> Result, String> { let socket = self - .qmp_socket - .as_ref() - .map(|s| s.path().to_path_buf()) + .sockets + .qmp() + .map(Path::to_path_buf) .expect("guest_memory needs BootOptions { qmp: true }"); // Beside the screendump, which is this instance's own scratch path. let out = self.screendump.with_extension(format!("mem-{phys:#x}")); @@ -3237,9 +3236,9 @@ impl QemuInstance { /// own reply. pub fn screendump(&mut self) -> super::screen::Ppm { let socket = self - .qmp_socket - .as_ref() - .map(|s| s.path().to_path_buf()) + .sockets + .qmp() + .map(Path::to_path_buf) .expect("screendump needs BootOptions { qmp: true }"); let out = self.screendump.clone(); let _ = fs::remove_file(&out); @@ -3555,12 +3554,12 @@ impl QemuInstance { /// The QMP socket this instance opened. Injection needs it, and it needs /// `BootOptions { qmp: true }`. pub fn qmp_socket(&self) -> &Path { - self.qmp_socket.as_ref().map(Socket::path).expect("qmp_socket needs BootOptions { qmp: true }") + self.sockets.qmp().expect("qmp_socket needs BootOptions { qmp: true }") } /// Stand on this guest's segment; it needs `BootOptions { segment: true }`. pub fn segment(&self) -> Result { - self.segment.as_ref().expect("segment needs BootOptions { segment: true }").open() + self.sockets.segment.as_ref().expect("segment needs BootOptions { segment: true }").open() } /// [`budget`] for a host-side wait on *this* guest, widened by the guest's @@ -3621,7 +3620,7 @@ impl QemuInstance { self.stdin.flush().expect("Failed to flush QEMU stdin"); let mut fire = - |line: &str, socket: Option<&Socket>| step(socket.map(Socket::path), line); + |line: &str, socket: Option<&Path>| step(socket, line); // `run [args...]`, and the markers carry only the binary name. let want = name.split_whitespace().next().unwrap_or(name); @@ -3691,7 +3690,7 @@ impl QemuInstance { Ok(line) => { last_line = Instant::now(); lines += 1; - fire(&line, self.qmp_socket.as_ref()); + fire(&line, self.sockets.qmp()); if dying.is_none() && super::serial::died(&line) == Some(super::serial::Died::Kernel) { @@ -3834,7 +3833,7 @@ impl Drop for QemuInstance { if let Some(image) = &self.own_boot_image { let _ = fs::remove_file(image); } - // The QMP and tap sockets' names go with their fields, after QEMU is reaped. + // `sockets` goes with the fields, after QEMU is reaped. LIVE.fetch_sub(1, Ordering::SeqCst); } } @@ -4341,8 +4340,8 @@ pub fn profile_argv(options: &BootOptions) -> Vec { let p = Path::new("/nonexistent"); let usb: Vec = options.profile.usb_disks().iter().map(|_| p.to_path_buf()).collect(); // A sequence no boot reaches, so no live boot's names are touched. - let segment = options.segment.then(|| super::segment::Tap::of_boot(u32::MAX)); - qemu_command(p, p, &usb, p, p, None, segment.as_ref(), options) + let sockets = Sockets { qmp: None, segment: options.segment.then(|| super::segment::Tap::of_boot(u32::MAX)) }; + qemu_command(p, p, &usb, p, p, &sockets, options) .get_args() .map(|a| a.to_string_lossy().into_owned()) .collect() @@ -4368,8 +4367,7 @@ fn qemu_command( usb_images: &[PathBuf], audio_wav: &Path, uart_log: &Path, - qmp_socket: Option<&Path>, - segment: Option<&super::segment::Tap>, + sockets: &Sockets, options: &BootOptions, ) -> Command { let shape = options.profile.shape(); @@ -4749,7 +4747,7 @@ fn qemu_command( qemu.arg("-object") .arg(format!("filter-dump,id=wire,netdev=net0,file={}", at.display())); } - if let Some(tap) = segment { + if let Some(tap) = &sockets.segment { assert!( !matches!(shape.nic, Nic::Absent), "this profile carries no NIC, so there is no `net0` segment to stand on" @@ -4803,7 +4801,7 @@ fn qemu_command( if options.gdb_stub { qemu.arg("-s"); } - if let Some(socket) = qmp_socket { + if let Some(socket) = sockets.qmp() { qemu.arg("-qmp") .arg(format!("unix:{},server,nowait", socket.display())); } @@ -4811,6 +4809,19 @@ fn qemu_command( qemu } +/// A boot's Unix sockets, named under `/tmp` by [`socketpath`] and removed when +/// this is dropped. +struct Sockets { + qmp: Option, + segment: Option, +} + +impl Sockets { + fn qmp(&self) -> Option<&Path> { + self.qmp.as_ref().map(Socket::path) + } +} + /// Every file one boot owns, so that adding another does not lengthen a /// parameter list eight paths long. struct Files { @@ -4819,8 +4830,7 @@ struct Files { uart_log: PathBuf, nvme: NvmeClaim, usb_images: Vec, - qmp_socket: Option, - segment: Option, + sockets: Sockets, screendump: PathBuf, own_boot_image: Option, carried: Option>, @@ -4894,8 +4904,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) uart_log, nvme, usb_images, - qmp_socket, - segment, + sockets, screendump, own_boot_image, carried, @@ -4995,8 +5004,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) uart_log, nvme, usb_images, - qmp_socket, - segment, + sockets, screendump, own_boot_image, boot_log, From 4e6ad1350bcb35b097b9a2eec6789abb7ec12045 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:02:56 +0200 Subject: [PATCH 5/8] run_test_paced calls its step directly The `fire` closure only turned `Option<&PathBuf>` into `Option<&Path>`, which `Sockets::qmp` now returns. Co-Authored-By: Claude Opus 5.5 --- tests/common/qemu.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index ab867bcc9f..fa08d004c9 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -3619,9 +3619,6 @@ impl QemuInstance { writeln!(self.stdin, "run {name}").expect("Failed to write to QEMU stdin"); self.stdin.flush().expect("Failed to flush QEMU stdin"); - let mut fire = - |line: &str, socket: Option<&Path>| step(socket, line); - // `run [args...]`, and the markers carry only the binary name. let want = name.split_whitespace().next().unwrap_or(name); if let Some(carried) = &self.carried { @@ -3690,7 +3687,7 @@ impl QemuInstance { Ok(line) => { last_line = Instant::now(); lines += 1; - fire(&line, self.sockets.qmp()); + step(self.sockets.qmp(), &line); if dying.is_none() && super::serial::died(&line) == Some(super::serial::Died::Kernel) { From 811da1d5700a12a8817a3173ac4891e10385b78c Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 00:04:04 +0200 Subject: [PATCH 6/8] issues: a killed run's socket names outlive it in /tmp The names moved out of the swept lane directory. A SIGKILLed run now leaves them, which is this branch's compromise, recorded with its exit. Co-Authored-By: Claude Opus 5.5 --- ...led-runs-socket-names-outlive-it-in-tmp.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 issues/build/a-killed-runs-socket-names-outlive-it-in-tmp.md diff --git a/issues/build/a-killed-runs-socket-names-outlive-it-in-tmp.md b/issues/build/a-killed-runs-socket-names-outlive-it-in-tmp.md new file mode 100644 index 0000000000..5d3edae214 --- /dev/null +++ b/issues/build/a-killed-runs-socket-names-outlive-it-in-tmp.md @@ -0,0 +1,23 @@ +--- +status: open +kind: tooling +opened: 2026-09-28 +--- + +# A killed run's socket names outlive it in `/tmp` + +`toyos_build::socketpath::short` names every harness Unix socket +`/tmp/toyos-