diff --git a/README.md b/README.md index 53175ad69d6..b95702c526b 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ itself on its own hardware and reproduces, byte for byte, what the host built. | ✅ | The whole OS booted across many machine shapes, in parallel, as an ordinary `cargo test` | | ✅ | An audio gate that compares captured device output against a recorded baseline, statistically | | ✅ | Kernel decisions in plain crates, tested on the host in milliseconds | -| ✅ | Reading a machine with no serial port — panics, logs and a blocked-task dump painted on its own panel | +| ✅ | Reading a machine with no serial port — panics and logs painted on its own panel | | ✅ | LLDB against a running kernel | | ⬜ | A Windows host | diff --git a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md index 5324b4860af..cb8f4a7aa3c 100644 --- a/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md +++ b/issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md @@ -136,6 +136,30 @@ the step each stopped in is not read from it: 77 (38 s, with ceilings paid at 8.00x and 31 s between its image and its first guest line) and 75 to 67 (7 s). +Three in one run, `wt/toyos-nohotkey` at `22b7bb464`, the whole suite, 41 of +44: `virt_smp` (`waiting for the job test_rs_counters_read to end`), +`virt_mask_windows` and `virt_off_names_the_cpus_left_on` (each `waiting for +the boot's last word`), all three verdicts within 4 s of the host's clock. +Load 54.08 60.22 55.97 when the suite began and 71.66 62.30 57.69 when it +ended, 14 cores; fastest boot 716 ms against the reference 1424 ms, ceilings +at 1.00x; workers 1309 s building against 1181 s testing. `virt_smp`'s last +three lines are this file's profile: `===TEST_END unmap_touch exit=0===` and +`===TEST_START test_rs_counters_read===` at 11.606 and 11.607, the spawn of +`pid=13` at 11.616 on cpu4. The other two said nothing while they were waited +on, so the step each stopped in is not read; `virt_off_names_the_cpus_left_on` +is also one of `issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md`'s +waits. The branch removes the blocked-task dump from `drain_irqs`, which every +pass runs. The three alone at that commit passed (load 56.98 to 54.99). Not +known: whether the guests ran; no register capture was taken. Against that +removal as the cause: at `2375834a6`, which already carries it and whose +guest kernel differs from `22b7bb464`'s only by three `pub` methods with no +caller, the whole suite passed 43 of 43, the same three among them, at load +28.66 rising to 61.52; what it took out of a pass is three unarmed checks +that each load an atomic and return; and the read is answered from the kick +vector (`counters::serve_here`), not from a pass. The three images were +built within 2 s of each other, after a 23 s wait on the artifact lock, and +the three guests failed together. + The fork compiler's ScalarEvolution fault (llvm/llvm-project#175729, which `src/miscompile.rs` now refuses a sysroot for) is not their cause. `tests/virtsmpcase`'s image was built and `virt_el1_smp` run by the compiler diff --git a/issues/a-double-fault-on-cpu-1-under-a-wide-suite.md b/issues/a-double-fault-on-cpu-1-under-a-wide-suite.md index 42e9837b87e..2e55aa738c7 100644 --- a/issues/a-double-fault-on-cpu-1-under-a-wide-suite.md +++ b/issues/a-double-fault-on-cpu-1-under-a-wide-suite.md @@ -93,8 +93,7 @@ to a failure arm reaches it: boot it with `BootOptions { qmp: true, .. }` so the guest survives the verdict, and ask `human-monitor-command` for `info registers -a` over the lane's socket to see what the *other* CPU was doing. Take that capture before injecting anything — a keystroke revives a -halted CPU, so Ctrl+Alt+D both confirms the diagnosis and destroys the evidence -for it. +halted CPU, so input over the same socket destroys the evidence for it. ## What the surviving line establishes about the machine diff --git a/issues/a-fatal-path-entered-outside-a-panic-holds-the-panel-with-interrupts-open.md b/issues/a-fatal-path-entered-outside-a-panic-holds-the-panel-with-interrupts-open.md index 7076b3620b0..9b36e9bbd2a 100644 --- a/issues/a-fatal-path-entered-outside-a-panic-holds-the-panel-with-interrupts-open.md +++ b/issues/a-fatal-path-entered-outside-a-panic-holds-the-panel-with-interrupts-open.md @@ -9,8 +9,8 @@ opened: 2026-10-03 `panic::halt_all_cpus` says the machine holds its report "with interrupts masked" and masks nothing itself: the panic handler and the exception entries mask before they reach it. Two callers do not, both in the test kernel alone: -the `panel-painter-stalls` actuator, from a scheduler pass -(`kernel/src/drivers/panic_console/mod.rs`'s `stall`), and `SYS_DEBUG`'s +the `panel-painter-stalls` actuator, from the boot thread after the last boot +phase (`kernel/src/drivers/panic_console/mod.rs`'s `stall`), and `SYS_DEBUG`'s `FATAL_HALT` (`kernel/src/syscall/dispatch.rs`). The CPU that then holds the panel spins on the reset bound with `IF` set, and every device handler pinned to it still runs on a kernel that has declared itself dead. @@ -22,7 +22,9 @@ and the halted one's has it clear. With `hold_the_panel` patched to poll port 0x60 and a key pressed inside the bound, six boots: the patched poll read the key in four, each with the panel's CPU cpu1, and never saw it in two. The one of those two whose console was kept had the panel on cpu0, where the i8042's -handler is pinned. +handler is pinned. Those boots staged the actuator from the Ctrl+Alt+D +report's painter, in a scheduler pass; that hotkey is removed, and the reading +has not been taken since the actuator moved to the boot thread. So that test stages a fatal path no shipping kernel takes. Not measured: that the handler read the byte in the two boots the poll missed it; the open `IF` diff --git a/issues/a-shared-boot-stopped-answering-and-no-capture-says-why.md b/issues/a-shared-boot-stopped-answering-and-no-capture-says-why.md index 5c3f09cea8f..c3cea5a4052 100644 --- a/issues/a-shared-boot-stopped-answering-and-no-capture-says-why.md +++ b/issues/a-shared-boot-stopped-answering-and-no-capture-says-why.md @@ -50,8 +50,8 @@ would leave a whole boot idle straight after a sibling thread's clean exit. path's two posts. **Exit condition.** A capture taken from a boot that has actually stopped, which -names the first waiter and the subject it waits on — the blocked-task dump, or -that boot's trace diary and panic records. +names the first waiter and the subject it waits on — that boot's trace diary +and panic records. **The periodic reporter is gone.** The owner ruled on 2026-10-04, choosing "Remove it entirely": "Delete the periodic report and its counters; hang diff --git a/issues/a-stop-shows-nothing-on-the-panel.md b/issues/a-stop-shows-nothing-on-the-panel.md index 1bff1ceb2e8..cde0f9f4d09 100644 --- a/issues/a-stop-shows-nothing-on-the-panel.md +++ b/issues/a-stop-shows-nothing-on-the-panel.md @@ -8,7 +8,7 @@ opened: 2026-10-04 A shutdown or reboot a press or a program asks for puts nothing on the screen: the kernel's panel console (`kernel/src/drivers/panic_console/`) paints -only a panic and the Ctrl+Alt+D report, and on a machine without a compositor, +only boot progress and a panic, and on a machine without a compositor, which is every test image, nothing else paints at all. On the T14 the owner pressed the power button and saw nothing happen, which is also what a machine that ignored the press looks like. diff --git a/issues/ap-tsc-trail-is-assumed-and-never-checked.md b/issues/ap-tsc-trail-is-assumed-and-never-checked.md index 6c71343d5a2..cd0118963b2 100644 --- a/issues/ap-tsc-trail-is-assumed-and-never-checked.md +++ b/issues/ap-tsc-trail-is-assumed-and-never-checked.md @@ -35,8 +35,7 @@ zero instead of a fault. record it writes with zero for as long as it trails, and those records read as the oldest thing the machine has: `log/read.rs`'s merge sorts them last, and its per-shard descent — which relies on `at_ns` descending with the sequence number -— stops at the first of them. A bracketed report (Ctrl+Alt+D) would then carry -nothing from that CPU. Every other consumer of `nanos_since_boot` on that CPU is +— stops at the first of them. Every other consumer of `nanos_since_boot` on that CPU is wrong by the same amount: deadlines, the scheduler's accounting, `at_ns` in `/log`. diff --git a/issues/blocked-dump-cannot-fire-on-a-total-freeze.md b/issues/blocked-dump-cannot-fire-on-a-total-freeze.md deleted file mode 100644 index 5c9ada3a8c1..00000000000 --- a/issues/blocked-dump-cannot-fire-on-a-total-freeze.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-08-06 ---- - -# The blocked-task dump cannot fire when no CPU reaches a scheduler pass - -A `CpuSched` is `!Sync` and reachable only from its own CPU, so walking a -sibling's queues is unwritable rather than racy. `task_cpu_ns` and -`task_sched_state` were rebuilt on values the owning CPU *publishes* — -`TaskHandle`'s counters, republished at each end of a pass, plus the core's -rendezvous word — so they are accurate and lock-free, which also closes the old -`try_lock`-and-skip misreport. `dump_blocked` had no such substitute: it printed -the calling CPU's parked map alone, by `TaskKey` and `WaitClass`, with no -process name, so it could confirm a park and never rule one out. - -`kernel/src/sched/dump.rs` replaces it, built for #172 and for #142's family. -It walks nothing remote: the asking CPU marks every sibling, kicks it, and each -prints its own tasks from `drain_irqs` at the top of its next pass. **A CPU that -does not reach a pass inside 250 ms is named, and that is a finding** — it is -the only way this report can say a CPU is not scheduling at all. - -Two halves, because neither sees what the other does. The CPUs give the -deadline, the wait class and how long the park has lasted; the process table -gives every thread's name and its published state, which is the only place a -task **no CPU was ever given** appears at all. `Ready` with `cpu_ns == 0` is -#142's signature, and the summary's `unheld` count is what the state words claim -minus what the CPUs hold. - -The three failure modes degrade rather than stop the report: a silent CPU is -named and the verdict says its counts are of what answered; the process table is -retried for 20 ms and then reported as held, with the deadline half of the -verdict still printed; and a CPU inside a pass says so. Nothing allocates, -nothing waits on a lock it could find held, and truncation takes only ordinary -lines — an overdue or absurd deadline is never dropped. - -It ends by painting the panel (`panic_console::paint_report`), taking the screen -from whoever holds it, because the machine it is for has no serial port and a -wedged one may never flush its log file. The verdict is the last line printed -and the console paints the newest page, so the screenful a phone camera catches -is the one carrying it. - -Gates: `blocked_dump` (eight CPUs, every one present, and the two halves' counts -must agree) and `screen_blocked_dump` (muted metal-sim, a compositor holding the -screen, the verdict read back off the decoded panel). Negative controls run: -without the per-CPU answer the report reaches 1 of 8, and with the userland -check restored on the paint the panel carries nothing. - -**A deadline that has passed and whose pass has not yet run is a real, -microsecond-wide state** — `blocked_dump` has seen `1 OVERDUE` on a healthy -guest. The count is evidence, not a verdict; what condemns a machine is one that -stays. - -**KNOWN BLIND SPOT: the dump cannot fire when no CPU reaches a scheduler pass.** -It is dispatched from `drain_irqs` at the top of a pass, so a *partial* wedge -answers and a *total* freeze is silent — the owner pressed it after pulling the -USB stick and got nothing, which is itself weak evidence that no CPU was -passing. Whether an interrupt-dispatched variant can close that is the -scheduler agent's (the NMI-on-timeout proposal). - -- **`Lock::lock` disables preemption, not interrupts** (`sync.rs`) — a ticket - spin after `preempt::disable()`, no `cli`. -- **That fact is load-bearing for the 250 ms sibling wait.** Spinning there is - safe *because a preempt-driven pass provably holds no `Lock`*: taking one - raises the preempt count, so a pass cannot be entered while one is held. - **The argument does not survive being moved to an interrupt**, which can land - on a CPU that *is* holding a lock — the waiter would then block the sibling - it is waiting for. `request`'s `assert!(depth <= 1)` encodes exactly this - entry condition and is not decoration. -- **`log!` takes the serial lock.** It is the single reason the report is not - ISR-safe today: an interrupt landing on a CPU that holds it deadlocks on the - first line. The panic console's paint is the lock-free counter-example and is - ISR-tolerable; `paint_report`'s `PAINTING` is a swap latch that self-releases, - not a `Lock`. -- **The per-CPU half is already reentrancy-safe by construction.** The - schedulers are not behind a `Lock` at all: `SCHEDS[cpu]` is guarded by the - `IN_PASS[cpu]` flag, mutation happens only inside `with_cpu` which sets it, - and `try_with_cpu` refuses when it is set. An interrupt that lands mid-pass - therefore reads nothing rather than reading a torn map. -- **The process table is `try_lock` retried to a 20 ms ceiling, and the retry - is not belt-and-braces.** Bare `try_lock` was the first version and - `screen_blocked_dump` caught it losing the whole census to a *transient* - holder — a spawn in flight 0.75 s into boot. The ceiling separates "someone - is mid-spawn" (microseconds) from "the holder is what is wedged" (never). -- **Truncation may never hide what the report is for.** `LINES_PER_CPU` bounds - ordinary parked lines; a line whose verdict is `Overdue` or `Absurd` is not - counted against the budget, and `UNPRINTED` says how many ordinary ones went. -- **The summary is last for a causal reason, not a cosmetic one**: it is the - only part that needs every CPU to have answered, and the console paints the - *newest* page, so last is what a photograph catches. Any variant that - reprints must keep that order. -- **The deadline arithmetic is guarded by its own classification.** - `Verdict::of` matches `at <= now` before it ever evaluates `at - now`, and - `Deadline`'s `Display` only subtracts in the arms that verdict produced. - Overflow checks are on in the kernel, so reordering those arms turns a - diagnostic into a panic. - -What is left of this entry: `ps` and `stats` still have no cross-CPU view of -anything the handles do not publish. - -`screen_blocked_dump` is intermittent **alone**, and at the same rate on an -untouched tree: five runs a side on 2026-08-07 gave three green and two red on -`main` at `48147c2` and the same on `wt/toyos-wedge`. A green of it is one -sample. The mechanism first recorded for that red — the ring tail lifting the -summary off the page — was wrong: userland repaints over the report, and the -failing string is the one that sat under the window rather than beside or below -it. That is what `panic_console::hold_report` answers. - -**A second red shape, now seen on two instruments: the panel carries no -`== VERDICT:` line at all.** The first occurrence was 2026-08-09: 12-wide, -520 s, the dev host carrying a second worktree's suite, then `ALONE: GREEN`. -The second is PR #33's KVM run `31472702284`, job `93736011023`, on merge ref -`1d19104d1b832da1aaad43906e0673cb87db93ba` (head -`ffbb17ab4d53a7b7fb9560be0cb68a4531434a3b`). Its first 70 s run decoded the -boot-log tail ending `[page 2/4]`, with none of `== deadlines:`, -`cpu(s) answered` or `== VERDICT:`. The harness's isolated re-run passed in -6 s and painted `== VERDICT: 0 overdue, 0 absurd, 0 unheld, 0 never ran`. -The CI job ran one guest at a time, so its initial and isolated executions are a -CI rate of one in two; the earlier loaded-dev-host occurrence is evidence on a -different instrument and is not folded into that rate. - -**The green count both halves retire on, as of 2026-09-04.** The CI half asks -for six consecutive green hosted runs and has **three**: nightly `ci` runs -`33485669019`, `33603832656` and `33728852421` on `main`, each `PASS -screen_blocked_dump (4s)`. The loaded-dev-host half asked for three loaded -observations and has them — green 3 of 3 beside a full `cargo test` fast tier -in one worktree, twelve guests up in each, painting `== VERDICT:` every time — -so this shape's remaining count is the CI one. -Three more green nightlies close it; one red under this name restarts it. - -The recorded repaint mechanism does not cover this shape — that one names a -string the window covered, while this panel is a paginated boot-log tail rather -than a dump missing only its last line. `dump-deaf-cpu` is ruled out despite -landing on `sched/dump.rs` hours before the first occurrence: `deaf_window` is -`#[cfg(feature = "dump-deaf-cpu")]`, only `dump_nmi_probe` asks for that feature -(`tests/common/faults.rs:321`), and `screen_blocked_dump` boots a kernel that -does not contain the function. - -`blocked_dump` is deleted; `issues/parallel-tests-red-under-other-suites.md` records the commit that restores it. `screen_blocked_dump` stays. diff --git a/issues/blocked-time-is-invisible-while-the-park-lasts.md b/issues/blocked-time-is-invisible-while-the-park-lasts.md index a0d12eb1dcd..97931e4a6a8 100644 --- a/issues/blocked-time-is-invisible-while-the-park-lasts.md +++ b/issues/blocked-time-is-invisible-while-the-park-lasts.md @@ -34,7 +34,7 @@ is charged to (`WaitClass` is the wait's now, named at the arm), not when. The fix has the same shape as `cpu_ns`'s and is not free: a reader would need the park's `since` stamp and its class from the owning CPU, and a `CpuSched` is -`!Sync` — `sched::dump`'s `for_each_parked` reaches only the calling CPU. +`!Sync` and reachable from its own CPU alone. `TaskHandle` is the cross-CPU face and could publish `(class, since)` at the park the way it publishes `running_since` at the dispatch, which is two relaxed stores on the park path. Whether that is worth two stores per park is the @@ -47,13 +47,7 @@ parked thread's blocked time is invisible until the park ends, exactly backwards for reading a wedge in progress. A scoped fix is named. Owed to whoever next extends the diagnostics the T14 wedge investigation started. -## Two corrections, 2026-09-01 - -**The Ctrl+Alt+D report already answers this and is not the site.** `report_this_cpu` -prints `task.class.name()` and `Ms(now.saturating_sub(task.since))` for a task -that is still parked (`kernel/src/sched/dump.rs:376-381`), reading -`ParkedInfo::since` — "When the park began" (`kernel/src/sched/driver.rs:830`). -The live interval and its class are both there. +## A correction, 2026-09-01 **`SYS_PROCESS_STATS` misses more than the park in progress.** `ProcessData::accounting`'s five `blocked_*_ns` fields are written by @@ -67,10 +61,3 @@ threads' parks only: every park a still-live thread has already finished is invisible too, not merely the one it is in. Publishing `(class, since)` at the park closes the smaller half; the larger half is a live thread's completed parks reaching a cross-CPU reader at all. - -**And the dump is not a substitute for the counters**, so "already answers this" -above is about the *site*, not about the need. It paints the panel on -Ctrl+Alt+D, never answers `SYS_PROCESS_STATS`; it prints at most -`LINES_PER_CPU` = 16 ordinary parked lines per CPU (`kernel/src/sched/dump.rs:32`, -truncating at `:370`) and counts the rest; and it reports a park's duration, not -the per-class totals `ps` reads. diff --git a/issues/desktop-window-child-freeze.md b/issues/desktop-window-child-freeze.md index 507410fdde4..07adb602ab3 100644 --- a/issues/desktop-window-child-freeze.md +++ b/issues/desktop-window-child-freeze.md @@ -134,11 +134,10 @@ machine that is still running passes somewhere. Judge the next occurrence by the signature exactly as before; a green run of this test proves what it always proved, which is nothing. -The one instrument that could name what a stopped CPU is doing has still never -been fired at one: `sched::dump`'s NMI probe separates a CPU spinning with `IF` -clear from one halted with its kick undelivered from one wedged below the -interrupt layer. Take `info registers -a` over QMP before pressing Ctrl+Alt+D, -which destroys what it reports on. +What a stopped CPU is doing has still never been read off one: in a guest, +`info registers -a` over QMP (`src/qemu.rs`) gives every vCPU's `RIP`, `RFL` +and `HLT`, and is taken before injecting anything, since a keystroke revives a +halted CPU. ## The reproduction was unreachable from 2026-08-27 to 2026-09-03, and is not any more @@ -167,11 +166,19 @@ calls "#156 did not fire this run, which proves nothing". What changed is that a red now means the desktop stopped answering, which is what the declaration was written about. -**Exit condition and owner.** Restored when #156 is fixed and a `sched::dump` -NMI probe taken on a reproduction confirms no CPU stopped taking scheduler -passes during the freeze — nothing short of that instrument distinguishes this -signature from a green run, which this entry has already shown proves nothing -either way. Owner: `kernel::sched`, the placement track that closed the +**Exit condition and owner.** Restored when #156 is fixed and a reproduction +confirms no CPU stopped taking scheduler passes during the freeze — nothing +short of that distinguishes this signature from a green run, which this entry +has already shown proves nothing either way. The instrument this exit named, +the blocked-task dump's NMI probe, is removed with its hotkey. What can read it +now is the kernel's trace diary (`kernel/src/trace.rs`), which every CPU writes +from its timer and its scheduler, printed by `/system/bin/trace`: a CPU whose +newest record stands behind the others' at the read stopped passing then, and +an `IdleEnter` as that record says it went to halt. Its rings hold 8192 +records a CPU, so a CPU that kept passing has overwritten what it said before +the freeze, and the read has to be taken by a program spawned after it, which +a reproduction whose desktop stopped answering may not allow. It has never +been taken on one. Owner: `kernel::sched`, the placement track that closed the CPU-selection half of this family (`CpuHandle::answering`, `kernel/pure/sched/cpu.rs`) and is nearest the remaining half; held by the orchestrator. diff --git a/issues/every-interrupt-lands-on-the-boot-cpu.md b/issues/every-interrupt-lands-on-the-boot-cpu.md index 12c0f95b9fc..3075dd1a6df 100644 --- a/issues/every-interrupt-lands-on-the-boot-cpu.md +++ b/issues/every-interrupt-lands-on-the-boot-cpu.md @@ -49,7 +49,7 @@ the machine's, and every device shares it. `kernel/src/irq_census.rs` counts every delivery per CPU per source in `PerCpu`, one `add qword ptr gs:[], 1` for the source; a CPU's total is their sum. `irq: cpuN timer=… kick=… …` is printed per CPU once a boot, where -the machine stops, and on the blocked-task dump. `irq_census_conservation` +the machine stops. `irq_census_conservation` gates the present-state fact on the T14, off the stop's census on the black-box page, and prints cpu0's share of that boot. diff --git a/issues/lan-hold-holds-a-boot-open-for-a-flat-twenty-seconds.md b/issues/lan-hold-holds-a-boot-open-for-a-flat-twenty-seconds.md deleted file mode 100644 index 55b1153b1be..00000000000 --- a/issues/lan-hold-holds-a-boot-open-for-a-flat-twenty-seconds.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-10-03 ---- - -# `lan_hold` holds a boot open for a flat twenty seconds - -`tests/toyos-rust-tests/src/bin/lan_hold.rs` sleeps `toyos_tco::LEASE_BOUND_MS` -and exits. The T14 boot `testcases-deaf` runs it as its one job, so the boot -does not end before the `dump-deaf-cpu` actuator has armed and dumped. It is a -fixed delay standing in for an event the job does not wait on, which root -`CLAUDE.md` forbids in a test. - -## Exit condition - -The boot's one job holds no sleep: it waits on the event it is held open for, -bounded by a timeout that panics by name, and `dump_nmi_probe` passes on a T14 -run of that head. diff --git a/issues/nothing-can-freeze-this-machine-from-outside.md b/issues/nothing-can-freeze-this-machine-from-outside.md deleted file mode 100644 index 51ef7a7b617..00000000000 --- a/issues/nothing-can-freeze-this-machine-from-outside.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -status: open -kind: track -opened: 2026-09-01 ---- - -# Nothing can freeze the whole machine from outside, so the last case the blocked dump exists for is unreachable - -`issues/blocked-dump-cannot-fire-on-a-total-freeze.md` records that -the request and service path runs only through `drain_irqs` -(`kernel/src/sched/driver.rs:652`), so every trigger the tree owns presupposes a -CPU that can still schedule that path. - -**Most of the machinery is already built, and this track is only the last piece.** -`tests/toyos.rs:6628`'s `freeze_report` opens a QMP monitor, takes -`info registers -a` *before* injecting anything, injects the dump trigger over -QMP as key events, then waits on serial for `=== end of dump ===` under a -30-second host deadline. That is register capture, host-side injection, serial -capture and a deadline — four of the five things the instrument needs. -`kernel/src/sched/dump.rs:240`'s `deaf_window` is an actuator for a CPU that -does not answer, and `tests/common/faults.rs:760`'s `dump_nmi_probe` asserts the -returned `rip` lands inside it (`:820`). So the partial case — one CPU deaf, -others scheduling — is reached today. - -**What is missing is the whole-machine case.** No QMP `stop`/`cont` appears -anywhere in `tests/` or `src/`: nothing suspends every vCPU and then asks -whether the dump path can still deliver. That is the state the dump exists for -and the one no guest workload can certify, because a guest that can arrange the -freeze can no longer report on it. - -**Two arms, because one of them can lie.** QMP `stop` can itself prevent the -handler from running, which looks identical to a dump path that does not work. -So the actuator is accepted only when both arms run — a "busy but interruptible" -guest and a QMP-stopped one — with the register trace distinguishing them. An -actuator that only ever produced the second is a tautology, not an instrument. - -**It joins the existing socket.** `BootOptions::qmp` is opt-in and per-instance, -and the monitor serves one caller at a time; the actuator uses that socket -rather than opening a second. diff --git a/issues/null-sink-applies-one-connect.md b/issues/null-sink-applies-one-connect.md index a2b0f21b38c..d2d49c182c3 100644 --- a/issues/null-sink-applies-one-connect.md +++ b/issues/null-sink-applies-one-connect.md @@ -51,10 +51,13 @@ closed `io_uring::cancel_by_source` lost wake is *not* this: the mix loop's only registration is on the command pipe, which soundd owns both ends of and nobody closes. -**What settles it on the next T14 boot: Ctrl+Alt+D on the wedged machine.** The -dump is machine-wide and process-named now, so one press answers the split -directly. soundd's mix thread parked with a sane deadline says the timer did -not fire; parked with an absurd one says the timeout was computed wrong; absent -from every parked list says nothing ever held it, which would move this into -#142's family rather than audio's. The report paints the panel, so the machine -with no serial port answers on glass and a photograph is enough. +**What settles it is where soundd's mix thread is parked and on what +deadline, and nothing in the tree reads the deadline off a running T14.** soundd's mix thread parked with a sane +deadline says the timer did not fire; parked with an absurd one says the +timeout was computed wrong; held by no CPU says nothing ever held it, which +would move this into #142's family rather than audio's. The kernel's trace +diary (`kernel/src/trace.rs`) records every park and wake, and +`/system/bin/trace` prints it: that says whether the mix thread parked and was +never woken, but no record carries the deadline it parked with. A park's +blocked time is invisible to `ps` until the park ends +(`issues/blocked-time-is-invisible-while-the-park-lasts.md`). diff --git a/issues/parallel-tests-red-under-other-suites.md b/issues/parallel-tests-red-under-other-suites.md index 82183d05c90..6ab5ae3e5e2 100644 --- a/issues/parallel-tests-red-under-other-suites.md +++ b/issues/parallel-tests-red-under-other-suites.md @@ -72,18 +72,12 @@ changes. - **`metal_sim_pointer_churn`** — observed once, on a host carrying three other suites *and* a `toyos-sched-sim` run. Not investigated. Still `Sched::Parallel`. -- **`dump_nmi_probe`** — retired 2026-09-04, green 3 of 3 beside a full fast - tier. `4ad8875` made it `Sched::Serial`, - which shows what serialising buys and what it does not: within one run the - phase is quiet. -- **`blocked_dump`** — retired 2026-09-04, green 3 of 3 beside a full fast - tier. - **`hda_tone`** — added 2026-08-07, hours after the test itself landed. In a full run on a host carrying another worktree's suite: `2 mid-tone silences in the capture: total 2 [3p×1 4p×1]`, `dither 3.3%`, `phase-breaks 92`. Alone on the same tree eight minutes later: `gaps none`, `phase-breaks 16` — the - declared #88 failure and nothing else. It is `Sched::Serial`, so like - `dump_nmi_probe` the harness never re-runs it alone and the run simply reds. + declared #88 failure and nothing else. It is `Sched::Serial`, so the harness + never re-runs it alone and the run simply reds. The tree it was seen on differed from main only in `src/`, so the guest image was byte-identical to main's. **Three times the same day**, all three in landing gates of that one @@ -481,7 +475,8 @@ A flaky test is deleted at once. Each commit below takes one out: - `metal_sim_pointer_churn` — `525e59ad1`; - `blocked_dump` — `0a7fc5f70`, red after its retirement here on the sightings `issues/log-reserve-window-negative-times-out-beside-other-guests.md` - and `issues/process-stats-exits-101-beside-other-guests.md` record; + and `issues/process-stats-exits-101-beside-other-guests.md` record; nothing + restores it, since the kernel no longer has the Ctrl+Alt+D report it drove; - `metal_sim_input` — `415d9a102`; - `xhci_full_speed_device` — `5e4223525`, with `Profile::MetalFullSpeed`. diff --git a/issues/pulling-the-boot-stick-freezes-the-t14.md b/issues/pulling-the-boot-stick-freezes-the-t14.md index c770338fc11..fd3b10003da 100644 --- a/issues/pulling-the-boot-stick-freezes-the-t14.md +++ b/issues/pulling-the-boot-stick-freezes-the-t14.md @@ -7,11 +7,11 @@ opened: 2026-08-06 # Pulling the boot stick freezes the T14, and the diagnosis that was wrong **The report.** Pulling the USB stick while the desktop is up freezes the whole -machine unrecoverably, from a USB-A or a USB-C port alike, and **Ctrl+Alt+D does -not answer afterwards**. That last clause is the strongest signal available: the -blocked-task dump is dispatched from `drain_irqs` at the top of a scheduler -pass, so no CPU is reaching a pass — not three of eight as in the wedge, all of -them. +machine unrecoverably, from a USB-A or a USB-C port alike, and **Ctrl+Alt+D did +not answer afterwards**. That last clause was the strongest signal available: the +blocked-task dump, since removed with its hotkey, was dispatched from +`drain_irqs` at the top of a scheduler pass, so no CPU was reaching a pass — not +three of eight as in the wedge, all of them. **A diagnosis to withdraw, recorded because it read well.** The mechanism first proposed was: every CPU entering a pass takes `XHCI`, one holds it across a full @@ -59,8 +59,9 @@ aims all of them at a dead device on one event. exactly as it already does across a debounce, and a teardown now takes one further scheduler pass. - **The metal claim is still the owner's to make.** Everything above is the - guest-side proxy — no pass blocks — and the acceptance test is a stick pulled - out of a running T14 with Ctrl+Alt+D still answering. + guest-side proxy — no pass blocks. The acceptance test was a stick pulled + out of a running T14 with Ctrl+Alt+D still answering; that hotkey is removed, + so what the pull must leave answering is the owner's to name again. - `log_file`'s flush still holds `SINK` and the VFS across device I/O. The doc's "unbounded and uninterruptible" is half right, and the precise reading is **bounded in acquisition, unbounded in work**: `poll` is `try_lock` on both and diff --git a/issues/spawned-process-never-starts.md b/issues/spawned-process-never-starts.md index 9e081900464..2a637c08789 100644 --- a/issues/spawned-process-never-starts.md +++ b/issues/spawned-process-never-starts.md @@ -86,9 +86,9 @@ instrument that would settle the split the `ps` column was going to. The assignment was reclaimed 2026-08-23: #142's investigation left no artifact. Investigation is the scheduler agent's (#142); the shapes are consistent with -one defect. Ctrl+Alt+D is now machine-wide and process-named, and on the -owner's laptop one press named three CPUs not reaching a scheduler pass and -three threads ready-and-never-run. +one defect. On the owner's laptop one press of the blocked-task dump's hotkey, +since removed, named three CPUs not reaching a scheduler pass and three threads +ready-and-never-run. ## The amplifier is closed, and it was most of the arithmetic @@ -98,8 +98,8 @@ advertises for ever the number it wrote on its way into idle, which is zero. That did not merely leave a shed core unused: it left it in the rotation as a CPU every spawn would rather have than a busy one, and an `Adopt` posted into a CPU that never drains is a task no balance path can reach. `InTransit` is one of -the words the dump renders as `ready`, which is why the census says `ready and -has never run` and `cpu_ns` says zero. +the words `task_sched_state` reports as `ready`, which is why the dump's census +said `ready and has never run` and `cpu_ns` says zero. It also fits the shape of this file's own numbers rather than contradicting it. Twelve `/system/bin/ls`, two of them hung, is a *fraction* lost and not everything — @@ -121,12 +121,16 @@ reverted (`kernel/sim/tests/policy.rs`, ## What is still open here, stated as the two things it is **Why a CPU stops reaching a pass at all.** Nothing in this work touched it and -nothing in it explains it. The instrument that can name it exists — -`sched::dump`'s NMI probe separates a CPU spinning with `IF` clear from one -halted with its kick undelivered from one wedged below the interrupt layer — -and it has never been fired at a machine in this state. Capture -`info registers -a` over QMP before pressing Ctrl+Alt+D, which destroys what it -reports on. +nothing in it explains it. In a guest, `info registers -a` over QMP +(`src/qemu.rs`) gives every vCPU's `RIP`, `RFL` and `HLT`, and is taken before +injecting anything, since a keystroke revives a halted CPU. On metal, a CPU +that has stopped taking interrupts is ended by `crate::hardlockup` on a boot +armed with a deadline and a CPU that states a PMU, and its record in the black +box (`kernel/src/blackbox.rs`) names where it stood. That reaches one of the +three causes the removed dump's NMI probe told apart, a CPU spinning with `IF` +clear: its header excludes a halted CPU, which its counter never samples, and +a CPU with `IF` set, which it never ends. Neither has been fired at a machine +in this state. **The one task per silent CPU.** The rule cannot do better without a liveness beat the idle path does not have, and adding one is an audio change diff --git a/issues/the-deaf-cpu-actuator-arms-on-three-seconds-of-guest-clock.md b/issues/the-deaf-cpu-actuator-arms-on-three-seconds-of-guest-clock.md deleted file mode 100644 index b21623dcf70..00000000000 --- a/issues/the-deaf-cpu-actuator-arms-on-three-seconds-of-guest-clock.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-24 ---- - -# The deaf-CPU actuator arms on three seconds of guest clock - -`kernel/src/sched/dump.rs`'s `deaf_window` (`dump-deaf-cpu`, driven by the -`dump_nmi_probe`) does nothing until `nanos_since_boot()` reaches its -`ARM_AT_NS`, 3 s, "late enough that the machine is up and every CPU has -joined". That is a sleep standing in for an event: a boot slower than 3 s puts -the deafening inside it, and a fast one idles until the clock passes. - -The same shape in `dump-in-blocking-pass` was a review BLOCKER on the branch -that filed this and was replaced by arming on `smp::is_ready()`, the SMP -release that is the machine's own word that every CPU has joined. This one was -off that branch's path. - -**Exit:** `deaf_window` arms on an event. diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index 96881f0e44f..59558431bb7 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -71,7 +71,6 @@ console's renderer. Exit: as stage A. refused while the first holds it. - host `screen_early_panel`: the panel repaints after each committed record. - host `screen_log_absent`: a `/log` that did not mount is said on the panel. -- host `screen_blocked_dump`: Ctrl+Alt+D's summary tells the three states apart. - host `screen_late_panic`: the fatal report is painted from the snapshot `capture()` froze, so a record committed after the capture is absent from the panel; and a frame wider than the panel wraps, its tail on the grid before the next frame (`check_wrap`). `screen_panic_muted` holds diff --git a/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md b/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md index 936917fbaa0..afb943f147e 100644 --- a/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md +++ b/issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md @@ -59,9 +59,8 @@ text: `lan_talk` and `lan_dhcp_lease`, which read what the host heard, and `lan_message_delivery` could not move to a boot that does not talk without a new job: the card's first message arrived 8.499 s into the last boot read, -with its link, so the boot has to be held open until then, and the one hold -the tree has is the flat sleep -`issues/lan-hold-holds-a-boot-open-for-a-flat-twenty-seconds.md` records. +with its link, so the boot has to be held open until then, and no job in the +tree holds a boot open on that event. ## What stands in the meantime diff --git a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md index b88f6056bf8..b7c0ab4fe28 100644 --- a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md +++ b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md @@ -72,9 +72,10 @@ times: invisible restart can come later. The kernel keeps ROOT's in-memory read path and exec from it. USB storage stays in the kernel until stage 5 moves the whole xHCI out, because its one IOMMU domain is shared with the - keyboard, and the panic console and the kernel's hotkeys never depend on a - userland USB program. No swap, declared. `SYS_DEVICE_DMA_MAP` for zero-copy - block I/O. FAT32 on `/log` in the installed product is deferred. + keyboard, and the panic console never depends on a userland USB program. + Ctrl+Alt+D is removed (owner, 2026-10-10: "i dont want it. i want it + removed."). No swap, declared. + `SYS_DEVICE_DMA_MAP` for zero-copy block I/O. FAT32 on `/log` in the installed product is deferred. Stages 3 and 4 are built as these steps, one pull request each: 1. **`toyos-blockring`**, the protocol, pure. **Exit**: an interleaving @@ -125,16 +126,10 @@ times: interrupts-off and preemption-off windows are measured. 10. **usbd**, stage 5's second half: the whole xHCI moves, HID to the keyboard claim and mass storage over `toyos-blockring`, and the kernel - USB bridge is deleted. **What must work with no userland stays off - USB**: the kernel's one - hotkey, Ctrl+Alt+D (`kernel/src/keyboard.rs`, the blocked-task dump), - is recognised on the i8042's transitions and no longer on a USB - keyboard's, which from here reach the kernel only as usbd's keyboard - claim and are never read for it. A machine whose only keyboard is USB - has no kernel hotkey from this step, declared. **Exit**, on the T14: - `/log` survives usbd killed mid-batch, the keyboard keeps working while - a stick misbehaves, and Ctrl+Alt+D on the machine's own keyboard files - the dump with usbd killed. QEMU's xHCI keeps its MSI-X table in the + USB bridge is deleted. A USB keyboard's transitions reach the kernel + only as usbd's keyboard claim. **Exit**, on the T14: `/log` survives + usbd killed mid-batch, and the keyboard keeps working while a stick + misbehaves. QEMU's xHCI keeps its MSI-X table in the BAR that holds the registers, which refuses usbd's claim as it refuses blockd's (`issues/a-controller-whose-msix-table-is-in-bar-0-cannot-be-driven-from-userland.md`), @@ -192,11 +187,9 @@ times: 4. **xHCI's thread is usbd's** (step 10 above): `Xhci`, `poll_if_pending` and `port_work_pending` go with the kernel's driver, and `irq_ring` and `sync::OwedLock`, whose one user is `XHCI`, with them. **Exit**: step 10's. - 5. **The pass is the scheduler's.** `drain_irqs` goes: the blocked-task - dump and the heartbeat become `pass`'s own, and the TCO feed stays, - since what it proves is that passes run. The dump keeps painting its - report on the panel and holding it there, a device the pass reaches - (owner, 2026-09-30). **Exit**: `drain_irqs` and the + 5. **The pass is the scheduler's.** `drain_irqs` goes: the heartbeat + becomes `pass`'s own, and the TCO feed stays, since what it proves is + that passes run. **Exit**: `drain_irqs` and the idle loop's device checks are gone, both windows are measured against step 2's readings by its rule, and the exits of `issues/an-irq-watchs-freeing-cancel-compiles-in-a-handler.md` @@ -209,8 +202,8 @@ times: exact ports through the TSS I/O permission bitmap and its ISA lines as records** (`kernel/src/isa.rs`). **Done** (#592). 2. **ps2server**, the server over that claim, feeding the kernel's keyboard - and mouse streams so Ctrl+Alt+D and the merge with USB HID stay where - they are; the kernel's driver, its vector, its actuators and the + and mouse streams so the merge with USB HID stays where it is; the + kernel's driver, its vector, its actuators and the `keyboard_controller` seam deleted. A keyboard claim is refused while no source exists, which init's order of endowment then decides. **Exit**: no i8042 code in the kernel, and typing resumes after ps2server is killed diff --git a/issues/timing-verdicts-ruled-off-qemu-have-no-metal-arm.md b/issues/timing-verdicts-ruled-off-qemu-have-no-metal-arm.md index 9e76cb85a81..9513eeab457 100644 --- a/issues/timing-verdicts-ruled-off-qemu-have-no-metal-arm.md +++ b/issues/timing-verdicts-ruled-off-qemu-have-no-metal-arm.md @@ -56,8 +56,7 @@ their clocks, and no `METAL` row judges them. (`log_stream_stalled_reader`). logd writes `/log` promptly while the machine runs (`kernel_log_file`, and `tests/common/usb.rs`'s stick with no write cache). -- **Panel and storage.** A dump survives the compositor's next repaint - (`screen_blocked_dump`). A same-length overwrite of a pinned `/home` file +- **Storage.** A same-length overwrite of a pinned `/home` file reads back whole through its displaced file's teardown: one read is taken now, and it may come before the teardown (`home_overwrite_reads_back`). diff --git a/issues/toyos-runs-on-arm64.md b/issues/toyos-runs-on-arm64.md index 10dd0b735c1..405332a519e 100644 --- a/issues/toyos-runs-on-arm64.md +++ b/issues/toyos-runs-on-arm64.md @@ -140,7 +140,7 @@ bound). Heaviest: `arch::cpu` (57 references), `arch::percpu` (28), (`scheduler.rs`, `sched/driver.rs`), `heartbeat.rs`, `irq_ring.rs`, `log/console.rs`. - **TSC.** 112 mentions in 11 non-arch files (`clock.rs`, `deadline.rs`, - `hardlockup/`, `panic_reboot.rs`, `xhci/`, `sched/dump.rs`). The bootloader + `hardlockup/`, `panic_reboot.rs`, `xhci/`). The bootloader calls `_rdtsc`/`__cpuid` and reads MSR 0x3B at `bootloader/src/main.rs:583-598`. - **CPU-state declaration.** `arch/control_regs.rs:1-6` is CR0/CR4/EFER; @@ -345,11 +345,7 @@ Each stage names its exit; "measured" means a number from a run. **Every CPU starts, ahead of small-kernel stage 6 by the owner's word, as stage 4 did:** it ports no device interrupt, so nothing of the relay. Owed before the exit holds: the TLS-descriptor resolver; - `issues/the-crash-evidence-records-x86-fault-registers.md`; the - blocked-task dump's probe of a CPU that ignored its kick - (`sched/dump.rs`'s `probe_silent`), which reaches `irqchip::send_nmi`'s - `owed!` on a machine of more than one CPU, and which nothing but the - `dump-deaf-cpu` actuator asks for until AArch64 has a keyboard; and, for + `issues/the-crash-evidence-records-x86-fault-registers.md`; and, for the clean of an AP's start block to the point of coherency, which TCG cannot fail on: `virt_el1_smp` under HVF started eight CPUs through PSCI in each of 131 boots of the entropy stage's measurement, all eight online diff --git a/issues/which-pass-drain-irqs-is-entered-from-is-gated-only-by-a-nightly-guest.md b/issues/which-pass-drain-irqs-is-entered-from-is-gated-only-by-a-nightly-guest.md deleted file mode 100644 index a6939c8b917..00000000000 --- a/issues/which-pass-drain-irqs-is-entered-from-is-gated-only-by-a-nightly-guest.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-09-28 ---- - -# Which pass `drain_irqs` is told it is entered from is gated only by a nightly guest - -`kernel-loom`'s `only_a_pass_entered_at_depth_zero_takes_the_request` holds -`Entered::may_serve`, the decision. The two call sites that choose its argument, -`kernel/src/sched/driver.rs`'s `pass` (`Entered::Pass` at the depth it was -entered at) and `pass_block` (`Entered::Blocking`), are in no crate a host test -compiles. PR #564's round-2 review handed `pass_block`'s drain -`Entered::Pass { depth: 0 }`: every host test and the fast tier stayed green, -and only the nightly `blocked_dump` went red. - -**Exit**: a mutation of either call site's `Entered` reds a host test. -Owner: orchestrator. - -`blocked_dump`, the nightly guest this names, is deleted; `issues/parallel-tests-red-under-other-suites.md` records the commit that restores it. Until then no test reds that mutation. diff --git a/kernel/CLAUDE.md b/kernel/CLAUDE.md index 0e95bc2ddb8..5af7dee15b6 100644 --- a/kernel/CLAUDE.md +++ b/kernel/CLAUDE.md @@ -20,4 +20,3 @@ The module header at the site owns its subsystem — read it before changing a m - **A page shared with userland is never reached through a Rust reference** — the words a protocol shares are `&AtomicU32` one at a time, everything else is a volatile copy of the whole value, and a page is laid out *before* it is mapped (`SharedMemObject::phys_before_mapping`). The kernel, `toyos-abi` and the SDK each hold one end of this rule. - **A device a process drives reaches only the memory its grants map** — the domain is the gate, not the descriptor; a device address outside a grant is a `DMA FAULT` record and never a crash. - **A bounds-checked accessor whose refusal panics inline is not inlined** — put the refusal in a `#[cold] #[inline(never)]` helper; and measure the emitted assembly before choosing a runtime check over a zero-sized witness. -- **Pressing Ctrl+Alt+D destroys the evidence it reports on** — capture `info registers -a` over QMP *first*; `kernel/src/sched/dump.rs` explains the report. diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index 334c8319800..eb55163f0e7 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -68,9 +68,6 @@ shard-publish-relaxed = [] # `device_irq` reds. They *are* the record's design, so turning them off reverts # the whole of what the model claims. device-irq-lossy = [] -# `src/sched/dump_request.rs`'s `take` and `end_report` go `Relaxed`, so the next report -# is unordered against the last one's writes, and `dump_request` reds. -dump-report-relaxed = [] # The same arrangement for the sleep lock's acquire edge, and it is the first # *contended*-acquire model this tree has: `src/sleeplock.rs`'s two loads of # `now` — the ones that decide whose turn it is — go `Relaxed`, so the previous diff --git a/kernel/loom/Cargo.toml b/kernel/loom/Cargo.toml index 3e57cfcbfda..319d8fc1575 100644 --- a/kernel/loom/Cargo.toml +++ b/kernel/loom/Cargo.toml @@ -175,16 +175,6 @@ sleeplock-acquire-off = [] # Never on by default and never reachable from a kernel build, which declares # the same name only so `cfg` checking knows it. device-irq-lossy = [] -# The negative control for the blocked-task dump's report handoff. It makes -# `dump_request.rs`'s `take` and `end_report` `Relaxed`, so a report is -# unordered against the one before it, and `dump_request.rs` must red: -# -# cargo test --manifest-path kernel/loom/Cargo.toml --features dump-report-relaxed \ -# --test dump_request -# -# Never on by default and never reachable from a kernel build, which declares -# the same name only so `cfg` checking knows it. -dump-report-relaxed = [] # The negative control for a program's log ring, `toyos/src/log/ring.rs`. It # makes the store that publishes a record — the shared ring's `published` word # and a lane's `head` — `Relaxed`, so a reader can find the word with the body diff --git a/kernel/loom/src/lib.rs b/kernel/loom/src/lib.rs index 8051dcc80d9..38883772d86 100644 --- a/kernel/loom/src/lib.rs +++ b/kernel/loom/src/lib.rs @@ -209,10 +209,6 @@ pub fn shards() -> [Option<&'static shard::Shard>; toyos_abi::log::MAX_LOG_SHARD #[path = "../../src/sched/reap_gate.rs"] pub mod reap_gate; -/// Ctrl+Alt+D's request word. Pure atomics, so it compiles here unshimmed. -#[path = "../../src/sched/dump_request.rs"] -pub mod dump_request; - /// The interrupt record a claimed PCI function's ISR writes and its holder /// reads. Pure `core` atomics and nothing else, so it compiles here unshimmed. #[path = "../../src/pcidev/record.rs"] diff --git a/kernel/loom/tests/dump_request.rs b/kernel/loom/tests/dump_request.rs deleted file mode 100644 index 538cf5ade57..00000000000 --- a/kernel/loom/tests/dump_request.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Loom: Ctrl+Alt+D's request word, a pass on each side of it. -//! -//! A thread here is a scheduler pass: one that may serve takes the request and -//! reports until a report ends with nothing filed during it, as -//! `sched::dump::serve` does; one that may not leaves it. The report's own state -//! is a cell, so two reports at once are loom's `Concurrent write accesses`. -//! -//! The negative control relaxes the report's handoff in the real word, and this file must red: -//! -//! ```text -//! cargo test --manifest-path kernel/loom/Cargo.toml --features dump-report-relaxed \ -//! --test dump_request -//! ``` -#![cfg(feature = "loom")] - -use kernel_loom::dump_request::{DumpRequest, Entered, Left}; -use loom::cell::UnsafeCell; -use loom::sync::Arc; - -struct Machine { - request: DumpRequest, - reports: UnsafeCell, -} - -impl Machine { - fn new() -> Arc { - Arc::new(Self { request: DumpRequest::new(), reports: UnsafeCell::new(0) }) - } - - fn report(&self) { - self.reports.with_mut(|reports| { - // SAFETY: the word's reporting bit is what makes this exclusive, and loom is the judge of that. - unsafe { *reports += 1 } - }); - } - - /// `sched::dump::serve`, without the read a pass with nothing pending pays instead: whether it took one. - fn serve(&self) -> bool { - if !self.request.take() { - return false; - } - loop { - self.report(); - if !self.request.end_report() { - return true; - } - } - } - - fn reports(&self) -> u32 { - self.reports.with(|reports| { - // SAFETY: every other thread has been joined. - unsafe { *reports } - }) - } -} - -/// The key pressed on one CPU while another reports: the second request gets a -/// report of its own, from the filing CPU's pass or from the first report's end, -/// and nothing is left pending with nobody to take it. -#[test] -fn a_request_filed_during_a_report_is_reported() { - loom::model(|| { - let machine = Machine::new(); - machine.request.file(); - assert!(machine.request.take(), "a pass that may serve did not take a pending request"); - - let filer = { - let machine = machine.clone(); - loom::thread::spawn(move || { - machine.request.file(); - machine.serve(); - }) - }; - - loop { - machine.report(); - if !machine.request.end_report() { - break; - } - } - filer.join().unwrap(); - - assert!(!machine.request.pending(), "a request is pending and no pass is obliged to take it"); - assert_eq!(machine.reports(), 2, "two requests, the second filed after the first was taken"); - }); -} - -/// Two passes that may serve, one request: one report. -#[test] -fn one_request_is_taken_once() { - loom::model(|| { - let machine = Machine::new(); - machine.request.file(); - - let other = { - let machine = machine.clone(); - loom::thread::spawn(move || machine.serve()) - }; - machine.serve(); - other.join().unwrap(); - - assert!(!machine.request.pending(), "the request outlived both passes"); - assert_eq!(machine.reports(), 1, "one request was reported other than once"); - }); -} - -/// Two passes that may not serve and one that may: the request is announced at -/// most once, and by nobody once it has been taken unannounced. -#[test] -fn a_request_is_announced_at_most_once() { - loom::model(|| { - let machine = Machine::new(); - machine.request.file(); - - let leaver = { - let machine = machine.clone(); - loom::thread::spawn(move || machine.request.leave()) - }; - let server = { - let machine = machine.clone(); - loom::thread::spawn(move || machine.serve()) - }; - let mine = machine.request.leave(); - let theirs = leaver.join().unwrap(); - server.join().unwrap(); - - assert!( - !(mine == Left::First && theirs == Left::First), - "two passes each said they were the first to leave one request", - ); - assert_eq!(machine.reports(), 1, "leaving a request cost it its report"); - }); -} - -/// A pass leaves a request while a report runs: the announcement survives in the -/// word beside the reporting bit, and the report's end still takes the request. -#[test] -fn a_request_left_during_a_report_is_still_taken_by_its_end() { - loom::model(|| { - let machine = Machine::new(); - machine.request.file(); - assert!(machine.request.take()); - - let leaver = { - let machine = machine.clone(); - loom::thread::spawn(move || { - machine.request.file(); - let left = machine.request.leave(); - assert_ne!(left, Left::Nothing, "a pass did not see the request it filed itself"); - }) - }; - leaver.join().unwrap(); - - machine.report(); - assert!(machine.request.end_report(), "the report's end did not take the request filed during it"); - machine.report(); - assert!(!machine.request.end_report()); - assert_eq!(machine.reports(), 2); - }); -} - -/// Only a pass entered at depth zero takes the request. A syscall's pass — `yield_now`, `exit_current` — -/// is entered above zero and a blocking pass is inside a wait ticket, and `dump::request` asserts it runs -/// with neither under it: a report served from one of them is a kernel panic. Not an interleaving question, -/// so no model. -#[test] -fn only_a_pass_entered_at_depth_zero_takes_the_request() { - assert!(Entered::Pass { depth: 0 }.may_serve()); - for entered in [Entered::Blocking, Entered::Pass { depth: 1 }, Entered::Pass { depth: 2 }] { - assert!(!entered.may_serve(), "{entered} took the request, and its report runs above a bare pass"); - } -} diff --git a/kernel/pure/sched/cpu.rs b/kernel/pure/sched/cpu.rs index 958a19bbf0f..bbfe8020e2b 100644 --- a/kernel/pure/sched/cpu.rs +++ b/kernel/pure/sched/cpu.rs @@ -128,10 +128,7 @@ pub struct ParkedEntry { class: WaitClass, } -/// One parked task as an outside reader sees it. The invariants want the key -/// and the deadline; a blocked-task dump wants the payload and how long the -/// park has lasted, and it is the only thing that can read them — a `CpuSched` -/// is reachable from its own CPU alone. +/// One parked task as an outside reader sees it. pub struct ParkedView<'a, X: SchedPayload> { key: TaskKey, entry: &'a ParkedEntry, @@ -146,19 +143,6 @@ impl ParkedView<'_, X> { self.entry.deadline } - pub fn class(&self) -> WaitClass { - self.entry.class - } - - /// When this park began. - pub fn since(&self) -> Nanos { - self.entry.task.since() - } - - pub fn ext(&self) -> &X { - self.entry.task.ext() - } - pub fn is_rt(&self) -> bool { self.entry.task.rt().is_rt() } @@ -448,12 +432,6 @@ impl CpuSched { self.steal_probe.in_flight() } - /// The number of ready tasks, republished to the handle every pass for - /// spawn placement. - pub fn ready_len(&self) -> usize { - self.rq.len() - } - /// Lend the running task an RT window: the path for a client /// that was *not* blocked when its producer signalled, and so takes the /// boost at its own consume point instead of through a wake cause. diff --git a/kernel/pure/sched/task.rs b/kernel/pure/sched/task.rs index c229b8413e6..b461608dd72 100644 --- a/kernel/pure/sched/task.rs +++ b/kernel/pure/sched/task.rs @@ -117,16 +117,6 @@ impl WaitClass { Self::Other => 4, } } - - pub fn name(self) -> &'static str { - match self { - Self::Io => "io", - Self::Futex => "futex", - Self::Pipe => "pipe", - Self::Ipc => "ipc", - Self::Other => "other", - } - } } /// Distinguishes one `park::prepare` from the next on the same task, so a @@ -834,13 +824,6 @@ impl Task { &self.0.acct } - /// When the current residency began. For a blocked task that is the - /// instant it parked — the one number that says whether a wait is a - /// moment old or the whole boot. - pub fn since(&self) -> Nanos { - self.0.since - } - /// The stable address of the saved context, for [`crate::sched::cpu::RunToken`]: /// the record is boxed, so it outlives every container move the task makes. pub(crate) fn ctx_ptr(&mut self) -> *mut X::Ctx { @@ -933,10 +916,6 @@ macro_rules! linear_state { pub fn acct(&self) -> &TaskAccounting { self.0.acct() } - - pub fn since(&self) -> Nanos { - self.0.since() - } } }; } diff --git a/kernel/sim/tests/deadline_claim_race.rs b/kernel/sim/tests/deadline_claim_race.rs index 663d9763e8e..9e14f765f49 100644 --- a/kernel/sim/tests/deadline_claim_race.rs +++ b/kernel/sim/tests/deadline_claim_race.rs @@ -9,10 +9,7 @@ //! executed in between, which is the only way to say it. //! //! What it asserts is invariant T across that window: a CPU that -//! holds a parked task with a pending deadline has its timer armed for it. The -//! state it constructs is the one `kernel/src/sched/dump.rs` cannot describe — -//! a machine halted with a parked thread and a stopped timer reports -//! `1 pending, 0 OVERDUE`, which is what health looks like. +//! holds a parked task with a pending deadline has its timer armed for it. //! //! It fails by aborting, like `scenarios::old_preemptible_window`: a task is a //! linear value with a drop bomb, so unwinding out of diff --git a/kernel/src/actuator.rs b/kernel/src/actuator.rs index 5dffb8814ab..615090f38a3 100644 --- a/kernel/src/actuator.rs +++ b/kernel/src/actuator.rs @@ -92,9 +92,6 @@ actuators! { /// by `machine_shutdown_wire_at_the_seal`. wire_held_at_the_last_word = "wire-held-at-the-last-word"; - /// Make one CPU ignore a kick. - dump_deaf_cpu = "dump-deaf-cpu"; - /// Wedge one CPU with interrupts off, spinning on a lock another CPU holds /// and never gives back: the negative control on `crate::hardlockup`, and a /// machine nothing else in this tree ends. Where CPUID states no @@ -189,8 +186,8 @@ actuators! { /// so a guest reaches the reset. Judged by `screen_fatal_behind_a_painter`. panic_reboot_fast = "panic-reboot-fast"; - /// Have Ctrl+Alt+D's report painter go fatal holding the panel's latch: a - /// fatal path meeting a painter that will never let go. + /// Have the last boot checkpoint's painter go fatal holding the panel's + /// latch: a fatal path meeting a painter that will never let go. panel_painter_stalls = "panel-painter-stalls"; } diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs index ebef83ce249..c5373db1519 100644 --- a/kernel/src/arch/aarch64/irqchip.rs +++ b/kernel/src/arch/aarch64/irqchip.rs @@ -324,6 +324,7 @@ pub fn send_self(vector: u8) { /// A pseudo-NMI: an interrupt at a priority `DAIF.I` does not mask, which /// needs `ICC_PMR_EL1` priority masking in place of `DAIF` everywhere. +#[cfg(feature = "boot-actuators")] pub fn send_nmi(_cpu: u32) { owed!("a pseudo-NMI", "no stage yet") } diff --git a/kernel/src/arch/x86_64/apic.rs b/kernel/src/arch/x86_64/apic.rs index b75a9160df0..03079ebe015 100644 --- a/kernel/src/arch/x86_64/apic.rs +++ b/kernel/src/arch/x86_64/apic.rs @@ -178,8 +178,9 @@ pub fn kick_all_but_self() { } } -/// Send a non-maskable interrupt to one CPU — for a CPU that failed to answer `kick_cpu`, since IF cannot mask NMI. -// Diagnostic only: an NMI can land inside any critical section, which this kernel cannot make NMI-safe. +/// Send a non-maskable interrupt to one CPU: a test kernel's staged NMI, since IF cannot mask one. +// Test kernels only: an NMI can land inside any critical section, which this kernel cannot make NMI-safe. +#[cfg(feature = "boot-actuators")] pub fn send_nmi(cpu_id: u32) { if !X2APIC_ENABLED.load(Ordering::Relaxed) { return; } let apic_id = crate::smp::hardware_id(cpu_id); diff --git a/kernel/src/arch/x86_64/idt/nmi.rs b/kernel/src/arch/x86_64/idt/nmi.rs index a9a8ab28e95..4c39edfb543 100644 --- a/kernel/src/arch/x86_64/idt/nmi.rs +++ b/kernel/src/arch/x86_64/idt/nmi.rs @@ -1,17 +1,13 @@ -//! Vector 2: probes a CPU that isn't answering an IPI kick by writing its -//! `rip` into a lock-free per-CPU slot for `sched::dump` to read from -//! ordinary context. Never logs, since the interrupted context may hold the -//! log ring's lock, and never reschedules, so no preempt-count or -//! exit-to-user check either. -//! Runs on IST2 for the `#DF` `arch::syscall`'s CPL-0/user-`rsp` window would -//! otherwise take; `PerCpu::nmi_active` guards IST2's non-reentrancy by -//! routing a second NMI to [`nested_nmi`] instead of corrupting the stack. -//! -//! It is also where `crate::hardlockup` samples the CPU it landed on, which is +//! Vector 2: where `crate::hardlockup` samples the CPU it landed on, which is //! the one bound a CPU with `IF` clear is under: the frame's `rip`, `rsp` and //! `rflags` are what a machine whose every CPU stopped taking interrupts has //! left to say. That path may seal a record and reset from here and never -//! return. +//! return. Never logs, since the interrupted context may hold the log ring's +//! lock, and never reschedules, so no preempt-count or exit-to-user check +//! either. +//! Runs on IST2 for the `#DF` `arch::syscall`'s CPL-0/user-`rsp` window would +//! otherwise take; `PerCpu::nmi_active` guards IST2's non-reentrancy by +//! routing a second NMI to [`nested_nmi`] instead of corrupting the stack. use core::arch::naked_asm; @@ -87,11 +83,8 @@ pub(super) extern "sysv64" fn nmi_entry() { extern "sysv64" fn note(rip: u64, rsp: u64, rflags: u64) { crate::arch::percpu::irq_took!(Nmi); - crate::sched::dump::note_nmi(rip); - // After the probe's store and before the nested-NMI staging: a hard lockup - // ends the machine from here, so the sibling asking where this CPU is still - // gets its answer, and nothing stages a second NMI onto a frame that is - // sealing a record. + // Before the nested-NMI staging: a hard lockup ends the machine from here, + // and nothing stages a second NMI onto a frame that is sealing a record. crate::hardlockup::sample(rip, rsp, rflags); #[cfg(feature = "boot-actuators")] stage_nested_if_armed(); diff --git a/kernel/src/clock.rs b/kernel/src/clock.rs index df0aef555ac..6b660cf7017 100644 --- a/kernel/src/clock.rs +++ b/kernel/src/clock.rs @@ -151,7 +151,7 @@ pub fn nanos_of_ticks(ticks: u64) -> u64 { /// Polls `ready` until it holds or `nanos` pass; `false` is the deadline. /// Reads the TSC, not [`nanos_since_boot`], because that clock's out-of-line divide -/// would appear as `dump_nmi_probe`'s red under an NMI sample. +/// would land an NMI sample's `rip` in `compiler_builtins` rather than in the wait. /// /// **Before [`init`] there is no period to measure a span with, so this asks /// `ready` once and answers it.** A wait nothing can bound is the one thing an diff --git a/kernel/src/drivers/panic_console/mod.rs b/kernel/src/drivers/panic_console/mod.rs index ec0ae38983a..dbb92989707 100644 --- a/kernel/src/drivers/panic_console/mod.rs +++ b/kernel/src/drivers/panic_console/mod.rs @@ -14,14 +14,14 @@ mod access; mod latch; use core::cell::UnsafeCell; -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use toyos_abi::boot::{KernelArgs, MemoryMapEntry}; use crate::log; use crate::log::LogStamp; use crate::panic_reboot::Bound; -use crate::time::{Budget, Cadence, Duration}; +use crate::time::{Budget, Duration}; use crate::mm::policy::MmioPolicy; use crate::mm::{self, DirectMap}; @@ -43,18 +43,6 @@ const _: () = assert!(SNAPSHOT_CAP >= MAX_ROWS * MAX_COLS); /// One two-bit [`Mark`] per byte `text` can hold — worst case is a message of nothing but newlines, one line per byte. const MARK_BYTES: usize = SNAPSHOT_CAP.div_ceil(4); -/// How long Ctrl+Alt+D's report keeps the panel. -const REPORT_HOLD: Budget = Budget::of( - Duration::from_secs(15), - "the report stops being put back and the desktop keeps its screen", -); - -/// How often the panel is checked for an overwrite while the report holds it. -const REPORT_CHECK: Cadence = Cadence::every( - Duration::from_millis(20), - "PROBES uncached reads per check, on the CPU that took an interrupt anyway", -); - #[derive(Clone, Copy)] struct Fb { ptr: *mut u8, @@ -300,15 +288,6 @@ const EARLY_CAPTOR: u32 = 1; /// path with no panic handler run) — separate from `SNAPSHOT` so a checkpoint cannot erase a captured, unpainted report. static LIVE: RenderedCell = RenderedCell(UnsafeCell::new(Rendered::EMPTY)); -/// Ctrl+Alt+D's report, rendered once and held for [`REPORT_HOLD`] by [`hold_report`]. -/// A separate buffer, not a live re-read, because it must survive a repaint after the ring has moved on. -static REPORT: RenderedCell = RenderedCell(UnsafeCell::new(Rendered::EMPTY)); - -/// When the report gives the panel back. 0 means it does not hold it. -static HOLD_UNTIL: AtomicU64 = AtomicU64::new(0); -/// Next `nanos_since_boot` a CPU may check the panel; a CAS picks one CPU of the eight. -static HOLD_CHECK_AT: AtomicU64 = AtomicU64::new(0); - /// Set once a process claims `DeviceType::Framebuffer`; a fatal panic ignores this and takes the screen back unconditionally. static SCREEN_OWNED_BY_USERLAND: AtomicBool = AtomicBool::new(false); @@ -679,7 +658,7 @@ pub fn render() -> bool { // Before the paint, from the same view the panel gets: a fault inside the // painter then costs the screen and not the copy the next boot reads. crate::blackbox::record_panic(text.text); - paint(Fill::Fatal, text, Watch::No, || false); + paint(Fill::Fatal, text, || false); true } @@ -722,7 +701,7 @@ pub fn boot_checkpoint() { repaint(); } -/// The `panel-painter-stalls` actuator: Ctrl+Alt+D's report painter, holding +/// The `panel-painter-stalls` actuator: a boot checkpoint's painter, holding /// the latch, goes fatal before it paints, so `screen_fatal_behind_a_painter` /// has a fatal path land on a latch whose holder is beneath it and will never /// give it back — the case of a painter halted by the halt IPI mid-paint. @@ -731,12 +710,10 @@ pub mod stall { /// What the painter says as it goes fatal; the test reads it off the panel. pub const HELD: &str = "panel: a painter holding the panel went fatal"; - pub(super) fn inside_the_latch() { - if !crate::actuator::panel_painter_stalls() { - return; - } + pub fn inside_the_latch() -> ! { + assert!(super::take_for_boot(), "panel-painter-stalls: the panel's latch was not free to take"); crate::log!("{HELD}"); - crate::panic::halt_all_cpus(); + crate::panic::halt_all_cpus() } } @@ -751,82 +728,10 @@ fn repaint() { if SCREEN_OWNED_BY_USERLAND.load(Ordering::Relaxed) || !take_for_boot() { return; } - paint(Fill::Boot, live_tail(), Watch::No, fatal_claimed); - PAINTING.store(false, Ordering::SeqCst); -} - -/// Put the log between two marks on the panel, and keep it there: a -/// bracket, not a tail, so the answer cannot land on the wrong page. -/// `from`/`to` are timestamps, not byte positions — a byte range has no -/// meaning across shards and could be widened by a concurrent writer. -/// -/// [`boot_checkpoint`] without the userland check — the keystroke is the -/// consent. A single paint is not a report, so this arms a hold that [`hold_report`] answers. -pub fn paint_report(from: LogStamp, to: LogStamp) { - // SAFETY: sound as `capture`'s `SNAPSHOT` write — `REPORT` is reached - // only from here and `report_text`; the dump reschedules every CPU, so only one is in flight. - let into = unsafe { &mut *REPORT.0.get() }; - into.render(from, to); - HOLD_UNTIL.store( - crate::clock::nanos_since_boot().saturating_add(REPORT_HOLD.nanos()), - Ordering::Relaxed, - ); - paint_held_report(); -} - -/// The report as it was when the dump finished, held for [`REPORT_HOLD`]; empty until one has been asked for. -fn report_text() -> View<'static> { - // SAFETY: sound as `paint_report`'s write — both callers run after it returns, and `paint_held_report` holds `PAINTING`. - unsafe { &*REPORT.0.get() }.view() -} - -fn paint_held_report() { - if !take_for_boot() { - return; - } - #[cfg(feature = "boot-actuators")] - stall::inside_the_latch(); - forget_the_glass(); - paint(Fill::Boot, report_text(), Watch::Yes, fatal_claimed); + paint(Fill::Boot, live_tail(), fatal_claimed); PAINTING.store(false, Ordering::SeqCst); } -/// Put the report back if the panel has stopped carrying it. Called from -/// `drain_irqs` on every CPU, every pass. Repaints on evidence, not a -/// timer: a genuinely stopped machine costs [`PROBES`] reads per -/// [`REPORT_CHECK`] and never a paint — a timer would blank and redraw every -/// tick, risking a black frame if a camera catches the gap. -pub fn hold_report() { - let until = HOLD_UNTIL.load(Ordering::Relaxed); - if until == 0 { - return; - } - let now = crate::clock::nanos_since_boot(); - if now >= until { - HOLD_UNTIL.store(0, Ordering::Relaxed); - return; - } - let due = HOLD_CHECK_AT.load(Ordering::Relaxed); - if now < due - || HOLD_CHECK_AT - .compare_exchange( - due, - now.saturating_add(REPORT_CHECK.nanos()), - Ordering::AcqRel, - Ordering::Relaxed, - ) - .is_err() - { - return; - } - let Some(fb) = snapshot() else { return }; - if !mapped(&fb) || panel_carries_report(&fb) { - return; - } - log!("panic console: the panel was drawn over, putting the report back"); - paint_held_report(); -} - /// Carries "halted" vs "still booting" at zero cost, and proves the console ran this boot. #[derive(Clone, Copy)] enum Fill { @@ -912,31 +817,6 @@ fn row_offsets(text: &[u8], cols: usize, first: usize, out: &mut [Row]) { } } -/// Whether a later pass must tell this paint apart from the panel being -/// drawn over. Only the report does — a checkpoint's screen is superseded, a fatal one's by nothing. -#[derive(Clone, Copy, PartialEq)] -enum Watch { - No, - Yes, -} - -/// Pixels of the watched paint remembered, so a later pass can tell "still up" from "drawn over" without a copy of the panel. -const PROBES: usize = 128; - -/// How many of `PROBES` are a grid over the panel rather than ink of the text, catching a repaint with no glyphs over it. -/// Neither replaces the other: a grid probe on the report's own black background can't catch a same-colour overwrite the way an ink probe can. -const GRID_PROBES: usize = 32; -const GRID_COLS: usize = 8; -const GRID_ROWS: usize = GRID_PROBES / GRID_COLS; -const _: () = assert!(GRID_COLS > 1 && GRID_ROWS > 1); - -/// One ink probe every this many inked glyphs, so probes span the page rather than clustering on its first line. -const PROBE_STRIDE: usize = 29; - -static PROBE_AT: [AtomicU32; PROBES] = [const { AtomicU32::new(0) }; PROBES]; -static PROBE_PX: [AtomicU32; PROBES] = [const { AtomicU32::new(0) }; PROBES]; -static PROBE_N: AtomicUsize = AtomicUsize::new(0); - /// One grid position as the panel left it: the character drawn there and its /// [`Ink`]. /// @@ -1101,7 +981,7 @@ fn spent(began: u64, pixels: u64) { /// `stop` is asked before every row and every scanline of a fill: a paint it /// answers yes to leaves at once, and the grid is forgotten, not believed. -fn paint(fill: Fill, view: View, watch: Watch, stop: impl Fn() -> bool) { +fn paint(fill: Fill, view: View, stop: impl Fn() -> bool) { let Some(fb) = snapshot() else { return }; if !mapped(&fb) { return; @@ -1144,8 +1024,6 @@ fn paint(fill: Fill, view: View, watch: Watch, stop: impl Fn() -> bool) { row_offsets(text, cols, first, &mut row_start[..draw]); let mut want_row = [Cell::GROUND; MAX_COLS]; - let mut inked = 0usize; - let mut probes = 0usize; for r in 0..grid_rows { if stop() { forget_the_glass(); @@ -1179,56 +1057,13 @@ fn paint(fill: Fill, view: View, watch: Watch, stop: impl Fn() -> bool) { pixels += draw_cell(&fb, c, r, cell, ground, palette.pixel(cell.ink())); *was = cell; } - if watch == Watch::Yes && text_row.is_some() { - if let Some((bx, by)) = cell.character().and_then(glyph_ink) { - if inked.is_multiple_of(PROBE_STRIDE) && probes < PROBES - GRID_PROBES { - let (x, y) = (c * GLYPH_W + bx, r * GLYPH_H + by); - PROBE_AT[probes].store(((y as u32) << 16) | x as u32, Ordering::Relaxed); - probes += 1; - } - inked += 1; - } - } } } flush_stores(); - if watch == Watch::Yes { - sample_probes(&fb, probes); - } spent(began, pixels); } -/// Read back what this paint left at each probe (not assumed), after the -/// grid is added to the draw loop's ink — a probe outside the published byte count was never written. -fn sample_probes(fb: &Fb, ink: usize) { - let mut n = ink.min(PROBES - GRID_PROBES); - let w = (fb.width as usize).saturating_sub(1); - let h = (fb.height as usize).saturating_sub(1); - for i in 0..GRID_PROBES { - // Corners included, where a taskbar or the strip below the last row sits, and where `Ppm::fill` reads. - let (x, y) = (w * (i % GRID_COLS) / (GRID_COLS - 1), h * (i / GRID_COLS) / (GRID_ROWS - 1)); - PROBE_AT[n].store(((y as u32) << 16) | x as u32, Ordering::Relaxed); - n += 1; - } - for i in 0..n { - let at = PROBE_AT[i].load(Ordering::Relaxed); - let px = get_pixel(fb, (at & 0xFFFF) as usize, (at >> 16) as usize); - PROBE_PX[i].store(px, Ordering::Relaxed); - } - PROBE_N.store(n, Ordering::Relaxed); -} - -/// Whether every probe still holds what the last paint left there; no probes means nothing painted, so this answers yes. -fn panel_carries_report(fb: &Fb) -> bool { - let n = PROBE_N.load(Ordering::Relaxed).min(PROBES); - (0..n).all(|i| { - let at = PROBE_AT[i].load(Ordering::Relaxed); - get_pixel(fb, (at & 0xFFFF) as usize, (at >> 16) as usize) - == PROBE_PX[i].load(Ordering::Relaxed) - }) -} - /// Put every store this module has made on the bus. fn flush_stores() { crate::arch::barrier::scanout_flush(); @@ -1294,19 +1129,6 @@ fn rgb(fb: &Fb, r: u32, g: u32, b: u32) -> u32 { } } -/// What is on the glass at one pixel, or 0 where nothing may be read — the -/// same clamp [`row_base`] applies, so a probe on a position that could not be written is never a false alarm. -fn get_pixel(fb: &Fb, x: usize, y: usize) -> u32 { - let Some(row) = (y as u64).checked_mul(fb.stride_px as u64) else { return 0 }; - let Some(idx) = row.checked_add(x as u64).and_then(|v| v.checked_mul(4)) else { return 0 }; - if idx.saturating_add(4) > fb.bytes { - return 0; - } - // SAFETY: a volatile read of the scanout has no safe spelling; bounded - // by `idx + 4 <= fb.bytes` above, and `validate` refusing a null/out-of-map base. - unsafe { core::ptr::read_volatile(fb.ptr.add(idx as usize) as *const u32) } -} - /// Base pointer for a row, given `len` pixels will be written from it. /// `None` means the row does not fit, and so does no later row. fn row_base(fb: &Fb, y: usize, len: usize) -> Option<*mut u32> { @@ -1356,26 +1178,12 @@ fn glyph_char(byte: u8) -> u8 { /// font would draw, which [`glyph_char`] does not produce. static NO_INK: [u8; GLYPH_H] = [0; GLYPH_H]; -/// The 16 rows the font draws [`glyph_char`]'s answer with; one mapping, so -/// [`glyph_ink`] cannot disagree with the draw. +/// The 16 rows the font draws [`glyph_char`]'s answer with. fn glyph(ch: u8) -> &'static [u8] { let base = ch.saturating_sub(0x20) as usize * GLYPH_H; FONT.get(base..base + GLYPH_H).unwrap_or(&NO_INK) } -/// A pixel of this character's glyph, in its cell. `None` for a glyph with no -/// ink — the only kind a probe must refuse, since a blank cell looks the same whether the report is still there or not. -fn glyph_ink(ch: u8) -> Option<(usize, usize)> { - for (row, &bits) in glyph(ch).iter().enumerate() { - for bit in 0..GLYPH_W { - if bits & (0x80 >> bit) != 0 { - return Some((bit, row)); - } - } - } - None -} - /// One cell's whole 8x16 block, ink and ground together, so a repaint clears /// nothing first: the block a cell leaves is exactly the block the next one /// writes. Proves the clamp once per glyph row rather than once per pixel. diff --git a/kernel/src/hardlockup/mod.rs b/kernel/src/hardlockup/mod.rs index 30b541879a2..5fea5f501e5 100644 --- a/kernel/src/hardlockup/mod.rs +++ b/kernel/src/hardlockup/mod.rs @@ -36,9 +36,8 @@ //! `log!` — the interrupted context may hold the log ring's own shard, and //! `src/sourcegate.rs`'s `nmi_does_not_log` is the gate. The sampling path is //! also on every CPU every second of every boot, so it is atomics and one -//! `rdtsc` and no division at all; the `dump_nmi_probe` test is the -//! instrument that once caught a 128-bit divide on an NMI-sampled path. The -//! report divides, and only after the machine is already being ended. +//! `rdtsc` and no division at all. The report divides, and only after the +//! machine is already being ended. //! //! # What it does not cover, stated rather than implied //! diff --git a/kernel/src/irq_census.rs b/kernel/src/irq_census.rs index 66dec389b6e..6be952f5f95 100644 --- a/kernel/src/irq_census.rs +++ b/kernel/src/irq_census.rs @@ -36,7 +36,7 @@ pub enum Source { Hda, /// Vector 0xFE, the TLB shootdown IPI. Tlb, - /// Vector 0x02, and `sched::dump` is its only sender. + /// Vector 0x02, which `crate::hardlockup` samples a CPU with. Nmi, /// Vector 0xFF, the local APIC's spurious vector. /// A non-zero count on a machine that staged nothing is an interrupt-routing defect. @@ -127,7 +127,7 @@ pub fn taken_here() -> u64 { } /// One `irq: cpuN =…` line per online CPU; counts are cumulative since boot. -/// The machine's reading, so the machine's to take: `crate::census`'s, and the blocked-task dump's, never one process's end. +/// The machine's reading, so the machine's to take: `crate::census`'s, never one process's end. /// Allocates nothing, takes no lock, touches no device. pub fn census(say: &mut impl FnMut(fmt::Arguments<'_>)) { for cpu in 0..crate::smp::cpu_count() { diff --git a/kernel/src/keyboard.rs b/kernel/src/keyboard.rs index 6a1f5da54ff..addaad88910 100644 --- a/kernel/src/keyboard.rs +++ b/kernel/src/keyboard.rs @@ -52,7 +52,6 @@ pub fn modifiers() -> u8 { /// Queue one key transition; a transition to an already-held state queues nothing. pub fn handle_key(usage: u8, pressed: bool) -> bool { - // Sole path into KEY_BUF; splitting per driver would miss a Ctrl+Alt+D pressed across two devices. if usage == 0 { return false; } @@ -67,12 +66,6 @@ pub fn handle_key(usage: u8, pressed: bool) -> bool { modifiers_of(&held) }; - // Ctrl+Alt+D: keyed by HID usage so it is the same three keys under every layout; recorded, not run, since the caller holds its driver's guard. - if pressed && modifiers & MOD_CTRL != 0 && modifiers & MOD_ALT != 0 && usage == 0x07 { - crate::sched::dump::file_request(); - return false; - } - let mut buf = KEY_BUF.lock(); if buf.len() >= MAX_QUEUED_EVENTS { buf.pop_front(); diff --git a/kernel/src/log/console.rs b/kernel/src/log/console.rs index a1d477a6310..eb84241f16e 100644 --- a/kernel/src/log/console.rs +++ b/kernel/src/log/console.rs @@ -239,7 +239,6 @@ fn discard_pending() { let mut sink = Discard; drain_ordered(&super::shards(), &mut cursor, &mut sink); DRAINED.put(&cursor); - LOST.store(DRAINED.lost(), Ordering::Relaxed); } /// At most `budget` records onto the wire the caller holds. Returns how many went. @@ -249,8 +248,6 @@ fn drain_records(wire: &SleepGuard<'_, ()>, budget: u64, yields: Yield) -> u64 { drain_ordered(&super::shards(), &mut cursor, &mut sink); let records = sink.records; DRAINED.put(&cursor); - RECORDS.fetch_add(records, Ordering::Relaxed); - LOST.store(DRAINED.lost(), Ordering::Relaxed); records } @@ -570,7 +567,6 @@ extern "C" fn body(_arg: u64) -> ! { continue; } // No deadline: a spurious wake costs a re-drain; a missing one is what W3's fences prevent. - PARKS.fetch_add(1, Ordering::Relaxed); // `klogd` is never killed, so this cancel arm is unreachable. let _ = watch::wait(&parkable, &armed, crate::time::Deadline::never()); } @@ -727,17 +723,3 @@ mod staged { } } } - -/// Three counters read by `sched::dump`: records drained, records lost, and parks. -static RECORDS: AtomicU64 = AtomicU64::new(0); -static LOST: AtomicU64 = AtomicU64::new(0); -static PARKS: AtomicU64 = AtomicU64::new(0); - -/// `(records drained, records lost, parks)`, via three relaxed loads. -pub fn stats() -> (u64, u64, u64) { - ( - RECORDS.load(Ordering::Relaxed), - LOST.load(Ordering::Relaxed), - PARKS.load(Ordering::Relaxed), - ) -} diff --git a/kernel/src/log/read.rs b/kernel/src/log/read.rs index 4034a2684a1..bfa94daf765 100644 --- a/kernel/src/log/read.rs +++ b/kernel/src/log/read.rs @@ -155,10 +155,6 @@ impl Published { self.lost.store(cursor.lost, Ordering::Relaxed); } - pub fn lost(&self) -> u64 { - self.lost.load(core::sync::atomic::Ordering::Relaxed) - } - /// Resets the position to the oldest record every shard still holds. pub fn rewind(&self) { use core::sync::atomic::Ordering; diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 64464538a19..cbf0cf818a5 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -563,6 +563,11 @@ pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { boot_phase!("complete", 0); report_power_on(kernel_args, complete); + #[cfg(feature = "boot-actuators")] + if actuator::panel_painter_stalls() { + drivers::panic_console::stall::inside_the_latch(); + } + #[cfg(feature = "boot-actuators")] if actuator::test_late_panic() { late_panic::Nest:: Option<&ThreadSched> { self.sched.as_ref() } pub fn state(&self) -> ThreadLocation { self.state } pub fn name(&self) -> &[u8; THREAD_NAME_LEN] { &self.name } - pub fn name_str(&self) -> &str { - core::str::from_utf8(&self.name).unwrap_or("?").trim_end_matches('\0') - } pub fn set_name(&mut self, name: &[u8]) { self.name = [0u8; THREAD_NAME_LEN]; let len = name.len().min(THREAD_NAME_LEN); @@ -740,47 +737,6 @@ pub fn init() { *PROCESS_TABLE.lock() = Some(ProcessTable::new()); } -/// One thread as a diagnostic sees it: who it is, and what the scheduler's cross-CPU-readable face says about it. -pub struct ThreadCensus<'a> { - pub pid: Pid, - pub tid: Tid, - pub process: &'a str, - pub thread: &'a str, - pub zombie: Option, - /// One of `sched::payload`'s `SCHED_*`; `None` between the table insert (tid allocated) and `sched::spawn` (task minted). - pub sched: Option, - /// Zero means the thread has never executed a user instruction. - pub cpu_ns: u64, -} - -/// Walk every thread in the table, or answer `false` because the table is held; `try_lock`, not `lock`, since the one time anybody asks is when the machine is stuck and whatever holds this lock is a candidate for what is stuck. -pub fn try_for_each_thread(mut f: impl FnMut(ThreadCensus<'_>)) -> bool { - let Some(guard) = PROCESS_TABLE.try_lock() else { - return false; - }; - let Some(table) = guard.as_ref() else { - return false; - }; - for (pid, proc) in table.iter() { - for (tid, thread) in proc.threads().iter() { - let sched = thread.sched(); - f(ThreadCensus { - pid, - tid, - process: proc.name_str(), - thread: thread.name_str(), - zombie: match thread.state() { - ThreadLocation::Zombie(code) => Some(code), - ThreadLocation::Scheduled => None, - }, - sched: sched.map(|s| s.sched_state()), - cpu_ns: sched.map_or(0, |s| s.handle.cpu_ns()), - }); - } - } - true -} - /// Accounting for a process. pub fn stats_of( object: &crate::object::process::ProcessObject, diff --git a/kernel/src/sched/driver.rs b/kernel/src/sched/driver.rs index ec61f50c915..d30da31f626 100644 --- a/kernel/src/sched/driver.rs +++ b/kernel/src/sched/driver.rs @@ -495,14 +495,12 @@ pub fn pass(dispose: Dispose) { crate::arch::cpu::df_witness_mutate(); #[cfg(feature = "df-witness")] crate::arch::cpu::df_witness("a scheduler pass"); - // Read before this pass's own level goes on top of it. - let entered = super::dump::Entered::Pass { depth: crate::preempt::count() }; crate::preempt::disable(); #[cfg(feature = "boot-actuators")] crate::deadline::wedge_if_staged(); // Must clear before it drains, so a wake from this pass's own drain survives into the next poll. crate::preempt::clear_need_resched(); - drain_irqs(entered); + drain_irqs(); // After `drain_irqs` and before the pass picks, so a wake this // posts is in the run queue by the time the pass chooses. crate::object::drain_zero_handles(); @@ -584,7 +582,7 @@ pub fn pass_block(ticket: Ticket, deadline: Option) { // No `preempt::disable()` of its own: the ticket has held the count raised since registration; that guard is this bracket. let ticket = ticket.into_raw(); crate::preempt::clear_need_resched(); - drain_irqs(super::dump::Entered::Blocking); + drain_irqs(); let now = HW.now(); let (action, parked) = with_cpu(|cpu| { let pass = SchedPass::begin(cpu, env(&PreemptOff(())), now); @@ -658,16 +656,9 @@ fn execute(action: Action) { } /// Consume this CPU's `irq_ring` records into wakes, before the mailbox drain, so a wake posted here reaches this pass's pick. -fn drain_irqs(entered: super::dump::Entered) { +fn drain_irqs() { crate::drivers::xhci::poll_if_pending(); crate::arch::keyboard_controller::service(); - // Here, not at the keystroke: the keystroke's decoding driver's guard is done by this point. - super::dump::serve_request(entered); - // A CPU cannot read a sibling's `CpuSched`, so the dump reaches every CPU - // by asking, and this is where each one answers. - super::dump::serve_if_owed(); - // Repaints the panel if whoever owns the screen has drawn over the report. - crate::drivers::panic_console::hold_report(); } /// Leave the current stack for this CPU's idle stack and never come back. @@ -686,12 +677,6 @@ pub fn enter_idle_loop() -> ! { extern "C" fn idle_loop() -> ! { loop { - // The idle loop, not a pass: the state it stages is a CPU that - // never reaches one. - #[cfg(feature = "boot-actuators")] - if crate::actuator::dump_deaf_cpu() { - super::dump::deaf_window(); - } // The first NMI; its handler stages the nested one. #[cfg(feature = "boot-actuators")] if crate::actuator::nmi_nested() { @@ -774,30 +759,6 @@ pub fn current_is_rt() -> bool { try_with_cpu(|cpu| cpu.running().is_some_and(|t| t.rt().is_rt())).unwrap_or(false) } -pub fn ready_len() -> usize { - try_with_cpu(|cpu| cpu.ready_len()).unwrap_or(0) -} - -/// Every thread on this CPU the machine's stop banded. -pub fn for_each_stopped(mut f: impl FnMut(TaskId)) -> bool { - try_with_cpu(|cpu| { - for task in cpu.stopped() { - f(task.ext().id); - } - }) - .is_some() -} - -/// Every dying thread on this CPU, in the order the pick will take them. -pub fn for_each_dying(mut f: impl FnMut(TaskId)) -> bool { - try_with_cpu(|cpu| { - for task in cpu.dying() { - f(task.ext().id); - } - }) - .is_some() -} - /// Every thread on this CPU's run queue, in pick order, for a staging that /// says why a woken thread has not run. `false` means a pass owns the state. #[cfg(feature = "boot-actuators")] @@ -810,37 +771,6 @@ pub fn for_each_ready(mut f: impl FnMut(TaskId)) -> bool { .is_some() } -/// The thread this CPU has loaded, if any. -pub fn running_id() -> Option { - try_with_cpu(|cpu| cpu.running().map(|t| t.ext().id)).flatten() -} - -/// One parked task, flattened because a `ParkedView` borrows the `CpuSched`, which nothing outside this file may hold. -pub struct ParkedInfo { - pub id: TaskId, - pub class: kernel::sched::task::WaitClass, - pub deadline: Option, - /// When the park began. - pub since: u64, - pub rt: bool, -} - -/// Walk this CPU's parked tasks. `false` means a pass owns the state right now. -pub fn for_each_parked(mut f: impl FnMut(ParkedInfo)) -> bool { - try_with_cpu(|cpu| { - for parked in cpu.parked() { - f(ParkedInfo { - id: parked.ext().id, - class: parked.class(), - deadline: parked.deadline().map(|n| n.0), - since: parked.since().0, - rt: parked.is_rt(), - }); - } - }) - .is_some() -} - /// Tail of the first switch into a fresh task. No lock to release, no outgoing task to park: only the /// preempt-count bracket's other half is owed. pub extern "C" fn trampoline_entry() { diff --git a/kernel/src/sched/dump.rs b/kernel/src/sched/dump.rs deleted file mode 100644 index fec28bd7cc4..00000000000 --- a/kernel/src/sched/dump.rs +++ /dev/null @@ -1,667 +0,0 @@ -//! Ctrl+Alt+D: what every CPU is holding, and what nothing is holding at all. -//! -//! No CPU reads another's scheduler state: the asking CPU marks and kicks -//! every sibling, and each prints its own tasks from `drain_irqs` next pass. -//! Nothing here allocates, waits on a lock it could find held, or leaves a -//! list unbounded. A CPU that misses the budget is named silent. Asking is -//! itself an interrupt, so a machine frozen on an unfired deadline reports -//! `0 OVERDUE`: summoning the report repairs the deadline it names. -//! -//! A request is served only by `driver::pass` entered at preempt depth zero — -//! no wait ticket and no `Lock` under it — and never by -//! `pass_block`, where a depth cannot tell a wait ticket from a level of the -//! pass's own. Every other pass leaves it pending and owes this CPU one that serves. -//! A request filed while a report runs is served by the pass that report runs in. - -use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - -pub use super::dump_request::Entered; -use super::dump_request::{DumpRequest, Left}; - -use crate::arch::{irqchip, percpu}; -use crate::smp; -use crate::sched::payload::{SCHED_BLOCKED, SCHED_READY, SCHED_RUNNING}; -use crate::time::{Budget, Duration, Floor}; - -use super::driver; -use super::MAX_CPUS; - -/// A [`Floor`]: nothing waits for this value, it only bounds another duration. -const ABSURD_HORIZON: Floor = Floor::policy( - Duration::from_secs(3_600), - "no kernel site parks for an hour, and an overflowed saturating_add lands 584 years out", -); - -/// How long the asking CPU spins with preemption off before naming the silent ones. -const ANSWER_BUDGET: Budget = Budget::of( - Duration::from_millis(250), - "the silent CPUs are named and their part of the report is missing", -); - -/// The depth [`request`] runs at from a pass entered at zero: the pass's own level. -const SERVING_DEPTH: u32 = 1; - -/// Cap on ordinary parked lines per CPU; anomaly lines are never truncated. -const LINES_PER_CPU: u32 = 16; - -/// Census lines, which carry only the threads the parked lists do not. -const CENSUS_LINES: u32 = 16; - -/// How long the census retries a held process table before giving up. -const TABLE_BUDGET: Budget = Budget::of( - Duration::from_millis(20), - "the summary says the census is missing rather than naming the threads no CPU has", -); - -/// How long a silent CPU gets to answer the NMI: far less than -/// [`ANSWER_BUDGET`] since an NMI needs no scheduler pass. -const NMI_BUDGET: Budget = Budget::of( - Duration::from_millis(1), - "the CPU is reported silent with no instruction pointer beside it", -); - -static REQUEST: DumpRequest = DumpRequest::new(); -static OWES: [AtomicBool; MAX_CPUS] = [const { AtomicBool::new(false) }; MAX_CPUS]; - -/// NMI handshake: the handler (`arch/x86_64/idt/nmi.rs`) may not allocate, log, or -/// lock, so it only stores and clears; the asking CPU reads. -static NMI_OWES: [AtomicBool; MAX_CPUS] = [const { AtomicBool::new(false) }; MAX_CPUS]; -static NMI_RIP: [AtomicU64; MAX_CPUS] = [const { AtomicU64::new(0) }; MAX_CPUS]; - -/// What the CPUs report, summed as each one answers; reset by `report`. -mod tally { - use core::sync::atomic::AtomicU32; - - pub static PARKED: AtomicU32 = AtomicU32::new(0); - pub static READY: AtomicU32 = AtomicU32::new(0); - /// Killed threads unwinding; counted separately since the census reads - /// their state word as `Ready`. - pub static DYING: AtomicU32 = AtomicU32::new(0); - /// Threads the machine's stop banded; counted separately for the same - /// reason, since no pick serves them and their state word reads `Ready`. - pub static STOPPED: AtomicU32 = AtomicU32::new(0); - pub static RUNNING: AtomicU32 = AtomicU32::new(0); - pub static NO_DEADLINE: AtomicU32 = AtomicU32::new(0); - pub static PENDING: AtomicU32 = AtomicU32::new(0); - pub static OVERDUE: AtomicU32 = AtomicU32::new(0); - pub static ABSURD: AtomicU32 = AtomicU32::new(0); - pub static UNPRINTED: AtomicU32 = AtomicU32::new(0); - - pub const ALL: [&AtomicU32; 10] = [ - &PARKED, - &READY, - &DYING, - &STOPPED, - &RUNNING, - &NO_DEADLINE, - &PENDING, - &OVERDUE, - &ABSURD, - &UNPRINTED, - ]; -} - -/// What a parked task's deadline says about it. -#[derive(Clone, Copy, PartialEq)] -enum Verdict { - /// No deadline: only an event ends this wait, which is what a server does. - Event, - /// A deadline still in the future. - Pending, - /// A deadline that passed without firing. - Overdue, - /// A deadline no wait could have meant. - Absurd, -} - -impl Verdict { - fn of(deadline: Option, now: u64) -> Self { - match deadline { - None => Self::Event, - Some(at) if at <= now => Self::Overdue, - Some(at) if at - now > ABSURD_HORIZON.nanos() => Self::Absurd, - Some(_) => Self::Pending, - } - } - - /// Whether this line must survive truncation. - fn is_anomaly(self) -> bool { - matches!(self, Self::Overdue | Self::Absurd) - } - - fn count(self) { - match self { - Self::Event => &tally::NO_DEADLINE, - Self::Pending => &tally::PENDING, - Self::Overdue => &tally::OVERDUE, - Self::Absurd => &tally::ABSURD, - } - .fetch_add(1, Ordering::Relaxed); - } -} - -fn online_cpus() -> usize { - (smp::cpu_count() as usize).min(MAX_CPUS) -} - -impl Entered { - fn under_nothing(self) -> Option { - self.may_serve().then_some(UnderNothing(())) - } -} - -/// Proof that the caller has nothing under it: minted by [`Entered::under_nothing`], and by the idle loop's actuator for itself. -struct UnderNothing(()); - -/// Ctrl+Alt+D pressed. Recorded, not run: the caller holds its driver's guard. -pub fn file_request() { - REQUEST.file(); -} - -/// Ctrl+Alt+D's request, from `drain_irqs` on every pass. -pub fn serve_request(entered: Entered) { - match entered.under_nothing() { - Some(proof) => serve(&proof), - None => leave_request(entered), - } -} - -/// A pass that may serve. While a report runs it takes nothing: that report's end does. -fn serve(proof: &UnderNothing) { - // Loaded first: a pass with nothing pending pays a read, not a locked update. - if !REQUEST.pending() || !REQUEST.take() { - return; - } - // Until a report ends with nothing filed during it. - loop { - report(proof); - if !REQUEST.end_report() { - return; - } - } -} - -/// A pass that may not serve: the request stays pending, and the first such pass says so. -fn leave_request(entered: Entered) { - let left = REQUEST.leave(); - if left == Left::Nothing { - return; - } - // Set by every pass that leaves one, not the first alone, since a pass clears it on entry: this - // CPU's next Ring 3 exit or `preempt::enable` to zero is then a pass entered at zero. - crate::preempt::set_need_resched(); - if left == Left::First { - log!( - "blocked-task dump: cpu{} met the request in {entered} and left it; a pass entered at \ - preempt depth 0 serves it, and this cpu owes one", - percpu::cpu_id(), - ); - } -} - -/// One report, on the CPU that took the request; the caller ends it. -fn report(_proof: &UnderNothing) { - let depth = crate::preempt::count(); - assert!( - depth <= SERVING_DEPTH, - "the blocked-task dump ran at preempt depth {depth}, above the {SERVING_DEPTH} of a pass \ - entered at zero" - ); - for counter in tally::ALL { - counter.store(0, Ordering::Relaxed); - } - - let cpus = online_cpus(); - let me = percpu::cpu_id() as usize; - // Two instants, not byte positions: there is no single stream across CPUs. - let from = log_stamp(); - log!("=== blocked-task dump: {cpus} cpu(s), and this report takes the screen ==="); - - // Indexed by cpu id: `OWES` is `MAX_CPUS` long regardless of `cpus`. - #[allow(clippy::needless_range_loop)] - for cpu in 0..cpus { - if cpu != me { - OWES[cpu].store(true, Ordering::Release); - } - } - // Every flag set before any kick, so an instant answer can't race its own flag. - for cpu in 0..cpus { - if cpu != me { - irqchip::kick_cpu(cpu as u32); - } - } - - report_this_cpu(); - - // A pass reached by preemption holds no `Lock`, so spinning can't block a sibling. - let deadline = crate::clock::nanos_since_boot().saturating_add(ANSWER_BUDGET.nanos()); - let mut silent = 0; - loop { - if (0..cpus).all(|cpu| !OWES[cpu].load(Ordering::Acquire)) { - break; - } - if crate::clock::nanos_since_boot() >= deadline { - let mut asked = [false; MAX_CPUS]; - for cpu in 0..cpus { - if OWES[cpu].swap(false, Ordering::AcqRel) { - silent += 1; - asked[cpu] = true; - log!(" cpu{cpu} !! no answer: it did not reach a scheduler pass"); - } - } - probe_silent(&asked, cpus); - break; - } - core::hint::spin_loop(); - } - - let census = census(); - summary(cpus, silent, census); - crate::drivers::panic_console::paint_report(from, log_stamp()); -} - -fn log_stamp() -> crate::log::LogStamp { - crate::clock::stamp().expect("the dump runs in a scheduler pass, after the clock starts") -} - -/// Sends an NMI to each CPU that ignored its kick: unlike a kick, an NMI -/// reaches a CPU regardless of IF, halt, or where it is wedged. -fn probe_silent(asked: &[bool; MAX_CPUS], cpus: usize) { - let any = (0..cpus).any(|cpu| asked[cpu]); - if !any { - return; - } - for cpu in 0..cpus { - if asked[cpu] { - NMI_RIP[cpu].store(0, Ordering::Relaxed); - NMI_OWES[cpu].store(true, Ordering::Release); - } - } - // Every flag set before any NMI, so an instant answer can't race its own flag. - // Indexed by cpu id: `cpu` doubles as the APIC id the NMI goes to. - #[allow(clippy::needless_range_loop)] - for cpu in 0..cpus { - if asked[cpu] { - irqchip::send_nmi(cpu as u32); - } - } - - let deadline = crate::clock::nanos_since_boot().saturating_add(NMI_BUDGET.nanos()); - while (0..cpus).any(|cpu| asked[cpu] && NMI_OWES[cpu].load(Ordering::Acquire)) { - if crate::clock::nanos_since_boot() >= deadline { - break; - } - core::hint::spin_loop(); - } - - for cpu in 0..cpus { - if !asked[cpu] { - continue; - } - if NMI_OWES[cpu].swap(false, Ordering::AcqRel) { - log!(" cpu{cpu} !! no NMI answer either: wedged below the interrupt layer"); - } else { - log!(" cpu{cpu} NMI answered, it is here:"); - crate::symbols::resolve_kernel(NMI_RIP[cpu].load(Ordering::Acquire)); - } - } -} - -/// Stages a CPU that ignores its kick: QEMU cannot make a guest CPU deaf on -/// its own, so a kernel actuator disables interrupts and spins on the victim. -/// Bounded and self-healing: the window is longer than [`ANSWER_BUDGET`] and -/// short enough that the guest still shuts down cleanly. -#[cfg(feature = "boot-actuators")] -pub(super) fn deaf_window() { - /// Late enough that the machine is up and every CPU has joined. - const ARM_AT_NS: u64 = 3_000_000_000; - /// Comfortably past [`ANSWER_BUDGET`], so silence isn't a race, and - /// bounded so the guest still shuts down. - const DEAF_NS: u64 = 400_000_000; - /// How long cpu0 waits for the victim to reach its idle loop; generous - /// because `drain_irqs` may reach USB enumeration first, leaving arrival unbounded. - const ACK_BUDGET_NS: u64 = 1_000_000_000; - - const IDLE: u64 = 0; - const ASKED: u64 = 1; - const DEAF: u64 = 2; - - static STAGE: AtomicU64 = AtomicU64::new(IDLE); - static FIRED: AtomicBool = AtomicBool::new(false); - - let cpus = online_cpus(); - if cpus < 2 { - return; - } - let me = percpu::cpu_id() as usize; - let victim = cpus - 1; - - if me == victim { - if STAGE - .compare_exchange(ASKED, DEAF, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - let began = crate::clock::nanos_since_boot(); - // `rdtsc`, not `nanos_since_boot`: the latter calls into - // `compiler_builtins`, which would misname where a stuck CPU is. - let until = crate::clock::tsc_deadline(DEAF_NS); - let deaf = crate::arch::IrqGuard::close(); - while crate::arch::cpu::counter() < until { - core::hint::spin_loop(); - } - drop(deaf); - // The victim's own log line is what proves the NMI interrupted it - // rather than killed it. - let deaf_ms = (crate::clock::nanos_since_boot() - began) / 1_000_000; - log!("dump-deaf-cpu: cpu{me} rejoined after {deaf_ms}ms deaf"); - } - return; - } - if me != 0 || crate::clock::nanos_since_boot() < ARM_AT_NS { - return; - } - if FIRED.swap(true, Ordering::AcqRel) { - return; - } - // Driven from here, not idle-loop iterations: cpu0 may halt between them. - STAGE.store(ASKED, Ordering::Release); - irqchip::kick_cpu(victim as u32); - let deadline = crate::clock::nanos_since_boot().saturating_add(ACK_BUDGET_NS); - while STAGE.load(Ordering::Acquire) != DEAF { - if crate::clock::nanos_since_boot() >= deadline { - // The CAS takes the ask back before giving up: if it fails, the - // victim just went deaf and the window is still open. - if STAGE - .compare_exchange(ASKED, IDLE, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - log!("dump-deaf-cpu: cpu{victim} did not reach its idle loop in time; asking again"); - FIRED.store(false, Ordering::Release); - return; - } - break; - } - core::hint::spin_loop(); - } - // Asked as the keystroke asks; the idle loop has nothing under it. - REQUEST.file(); - serve(&UnderNothing(())); -} - -/// Where this CPU was, for the NMI probe. Called only from `arch/x86_64/idt/nmi.rs`. -/// Stores unconditionally: reading the flag first would race the requester that owns it. -pub fn note_nmi(rip: u64) { - let me = percpu::cpu_id() as usize; - if me >= MAX_CPUS { - return; - } - NMI_RIP[me].store(rip, Ordering::Release); - NMI_OWES[me].store(false, Ordering::Release); -} - -/// Prints this CPU's own tasks if asked. Called from `drain_irqs` every pass. -pub fn serve_if_owed() { - let me = percpu::cpu_id() as usize; - if me >= MAX_CPUS || !OWES[me].load(Ordering::Acquire) { - return; - } - report_this_cpu(); - OWES[me].store(false, Ordering::Release); -} - -fn report_this_cpu() { - let cpu = percpu::cpu_id(); - let now = crate::clock::nanos_since_boot(); - - match driver::running_id() { - Some(id) => { - tally::RUNNING.fetch_add(1, Ordering::Relaxed); - log!(" cpu{cpu} running pid={} tid={}", id.0.raw(), id.1.raw()); - } - None => log!(" cpu{cpu} running nothing"), - } - let ready = driver::ready_len() as u32; - tally::READY.fetch_add(ready, Ordering::Relaxed); - - // Read separately: a killed thread's state word reads `Ready(cpu)`, so - // without this list every teardown in flight is an `unheld` false positive. - let mut dying = 0u32; - let read_dying = driver::for_each_dying(|id| { - dying += 1; - tally::DYING.fetch_add(1, Ordering::Relaxed); - log!(" cpu{cpu} pid={} tid={} unwinding (killed)", id.0.raw(), id.1.raw()); - }); - if !read_dying { - log!(" cpu{cpu} !! a pass owns its scheduler state; nothing read from it"); - } - - let mut stopped = 0u32; - let read_stopped = driver::for_each_stopped(|id| { - tally::STOPPED.fetch_add(1, Ordering::Relaxed); - stopped += 1; - if stopped > LINES_PER_CPU { - tally::UNPRINTED.fetch_add(1, Ordering::Relaxed); - return; - } - log!(" cpu{cpu} pid={} tid={} stopped (the machine is stopping)", id.0.raw(), id.1.raw()); - }); - if !read_stopped { - log!(" cpu{cpu} !! a pass owns its scheduler state; nothing read from it"); - } - - let mut ordinary = 0u32; - let read = driver::for_each_parked(|task| { - tally::PARKED.fetch_add(1, Ordering::Relaxed); - let verdict = Verdict::of(task.deadline, now); - verdict.count(); - if !verdict.is_anomaly() { - ordinary += 1; - if ordinary > LINES_PER_CPU { - tally::UNPRINTED.fetch_add(1, Ordering::Relaxed); - return; - } - } - log!( - " cpu{cpu} {}pid={} tid={} {} parked {} {}{}", - if verdict.is_anomaly() { "!! " } else { "" }, - task.id.0.raw(), - task.id.1.raw(), - task.class.name(), - Ms(now.saturating_sub(task.since)), - Deadline { verdict, deadline: task.deadline, now }, - if task.rt { " rt" } else { "" }, - ); - }); - if !read { - log!(" cpu{cpu} !! a pass owns its scheduler state; nothing read from it"); - } - if ready > 0 { - log!(" cpu{cpu} {ready} task(s) queued and not running"); - } -} - -/// What the census found, for the summary to compare against the CPUs. -#[derive(Default, Clone, Copy)] -struct Census { - read: bool, - threads: u32, - running: u32, - ready: u32, - blocked: u32, - zombie: u32, - unscheduled: u32, - never_ran: u32, -} - -/// Every thread the process table knows, one line for each the CPUs' parked -/// lists do not carry. -fn census() -> Census { - let mut c = Census::default(); - let mut printed = 0u32; - let deadline = crate::clock::nanos_since_boot().saturating_add(TABLE_BUDGET.nanos()); - c.read = walk_threads(deadline, |thread| { - c.threads += 1; - if thread.zombie.is_some() { - c.zombie += 1; - return; - } - // Kernel threads are named even though usually blocked: their parked - // lines carry only a pid, and a frozen machine needs to know which. - let kernel = crate::sched::kthread::is_kernel_task(crate::scheduler::TaskId( - thread.pid, - thread.tid, - )); - let (bucket, tag) = match thread.sched { - Some(SCHED_RUNNING) => (&mut c.running, kernel.then_some("kernel")), - Some(SCHED_BLOCKED) => (&mut c.blocked, kernel.then_some("kernel")), - // `cpu_ns == 0` on a Ready thread means it has never run. - Some(SCHED_READY) if thread.cpu_ns == 0 => { - c.never_ran += 1; - (&mut c.ready, Some("!! ready and has never run")) - } - Some(SCHED_READY) => (&mut c.ready, Some("ready")), - _ => (&mut c.unscheduled, Some("!! no scheduler record")), - }; - *bucket += 1; - // Blocked and running threads are already the CPUs' lines; skip them here. - let Some(tag) = tag else { return }; - // Kernel threads don't count against the budget: `MAX_KERNEL_TASKS` - // bounds them, so counting them can't push these lines off the page. - if !kernel { - printed += 1; - if printed > CENSUS_LINES { - return; - } - } - log!( - " {tag}: pid={} tid={} {} cpu={}", - thread.pid.raw(), - thread.tid.raw(), - Named { process: thread.process, thread: thread.thread }, - Ms(thread.cpu_ns), - ); - }); - c -} - -/// Retries `try_for_each_thread` until `deadline`. -fn walk_threads(deadline: u64, mut f: impl FnMut(crate::process::ThreadCensus<'_>)) -> bool { - loop { - if crate::process::try_for_each_thread(&mut f) { - return true; - } - if crate::clock::nanos_since_boot() >= deadline { - return false; - } - core::hint::spin_loop(); - } -} - -/// The verdict, printed last since it needs every CPU to have answered. -fn summary(cpus: usize, silent: u32, c: Census) { - let answered = cpus - silent as usize; - // Needs nothing from the CPU it describes: the counters are `PerCpu`'s own, read by a sibling. - crate::irq_census::census(&mut |line| log!("{line}")); - // Each count is written before the word it counts, so the gate parses by word, not position. - if !c.read { - log!("== census: the process table is held; no thread census this dump"); - } else { - log!( - "== census: {} thread(s) — {} running, {} ready, {} blocked, {} zombie, {} unscheduled", - c.threads, c.running, c.ready, c.blocked, c.zombie, c.unscheduled, - ); - } - log!( - "== sched: {answered}/{cpus} cpu(s) answered — {} running, {} queued, {} unwinding, \ - {} stopped, {} parked", - tally::RUNNING.load(Ordering::Relaxed), - tally::READY.load(Ordering::Relaxed), - tally::DYING.load(Ordering::Relaxed), - tally::STOPPED.load(Ordering::Relaxed), - tally::PARKED.load(Ordering::Relaxed), - ); - log!( - "== deadlines: {} event-only, {} pending, {} OVERDUE, {} ABSURD", - tally::NO_DEADLINE.load(Ordering::Relaxed), - tally::PENDING.load(Ordering::Relaxed), - tally::OVERDUE.load(Ordering::Relaxed), - tally::ABSURD.load(Ordering::Relaxed), - ); - // `unheld` compares what the state words claim against what the CPUs - // actually hold: a thread claimed but not held will never run. - let overdue = tally::OVERDUE.load(Ordering::Relaxed); - let absurd = tally::ABSURD.load(Ordering::Relaxed); - if c.read { - // `DYING` and `STOPPED` are summed with `READY`: the census counts those threads as ready too. - let scheduled = tally::PARKED.load(Ordering::Relaxed) - + tally::READY.load(Ordering::Relaxed) - + tally::DYING.load(Ordering::Relaxed) - + tally::STOPPED.load(Ordering::Relaxed) - + tally::RUNNING.load(Ordering::Relaxed); - let claimed = c.running + c.ready + c.blocked; - log!( - "== VERDICT: {overdue} overdue, {absurd} absurd, {} unheld, {} never ran{}", - claimed.saturating_sub(scheduled), - c.never_ran, - if answered == cpus { "" } else { " (of the cpus that answered)" }, - ); - } else { - log!( - "== VERDICT: {overdue} overdue, {absurd} absurd — the process table would not \ - open, so nothing here can say what no cpu holds", - ); - } - // `lost` is the one count a console reader could never derive on their own. - let (drained, lost, parks) = crate::log::console::stats(); - log!("== klogd: {drained} record(s) drained, {lost} lost, {parks} park(s)"); - let unprinted = tally::UNPRINTED.load(Ordering::Relaxed); - if unprinted > 0 { - log!("== {unprinted} ordinary parked or stopped task(s) not listed; every anomaly is"); - } - log!("=== end of dump ==="); -} - -/// A thread by its two names; empty `thread` falls back to `process` alone. -struct Named<'a> { - process: &'a str, - thread: &'a str, -} - -impl core::fmt::Display for Named<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self.thread.is_empty() { - true => write!(f, "{}", self.process), - false => write!(f, "{}:{}", self.process, self.thread), - } - } -} - -/// Milliseconds: every duration in this report is one. -struct Ms(u64); - -impl core::fmt::Display for Ms { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "{}ms", self.0 / 1_000_000) - } -} - -struct Deadline { - verdict: Verdict, - deadline: Option, - now: u64, -} - -impl core::fmt::Display for Deadline { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match (self.verdict, self.deadline) { - (Verdict::Event, _) => write!(f, "no deadline"), - (Verdict::Pending, Some(at)) => write!(f, "due in {}", Ms(at - self.now)), - (Verdict::Overdue, Some(at)) => { - write!(f, "OVERDUE by {}", Ms(self.now.saturating_sub(at))) - } - (Verdict::Absurd, Some(at)) => write!(f, "ABSURD, due in {}", Ms(at - self.now)), - // Unreachable in practice: `Verdict::of` reads the same `Option`. - (_, None) => write!(f, "no deadline"), - } - } -} diff --git a/kernel/src/sched/dump_request.rs b/kernel/src/sched/dump_request.rs deleted file mode 100644 index c6a18b705ad..00000000000 --- a/kernel/src/sched/dump_request.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Ctrl+Alt+D's request and the report it asks for, as one word: what is pending, and whether a report runs. -//! Every transition is one atomic update of that word, so a request is announced once and taken once, no -//! report begins inside another, and a request filed during a report is taken by that report's end. -//! [`Entered`] is which pass may take a request. -//! No `crate::` references: `kernel-loom` compiles this file directly under `feature = "loom"`. - -#[cfg(not(feature = "loom"))] -use core::sync::atomic::{AtomicU8, Ordering}; - -#[cfg(feature = "loom")] -use loom::sync::atomic::{AtomicU8, Ordering}; - -/// The report's handoff: what one report wrote, the next to take the word reads. `dump-report-relaxed` makes -/// it `Relaxed` so `kernel-loom` can prove the edge is load-bearing; no kernel build turns it on. -#[cfg(not(feature = "dump-report-relaxed"))] -const HANDOFF: (Ordering, Ordering) = (Ordering::AcqRel, Ordering::Acquire); -#[cfg(feature = "dump-report-relaxed")] -const HANDOFF: (Ordering, Ordering) = (Ordering::Relaxed, Ordering::Relaxed); - -/// The bits that say what is pending. -const PENDING: u8 = 0b011; -const NONE: u8 = 0; -/// Asked for, and no pass has said it left it. -const REQUESTED: u8 = 1; -/// Asked for, and a pass that may not serve it has said so. -const ANNOUNCED: u8 = 2; -/// A report runs; whoever set it owns the report's state until it clears it. -const REPORTING: u8 = 0b100; - -/// What a pass that may not serve the request found. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Left { - Nothing, - /// Pending, and this pass is the first to leave it: the one that says so. - First, - /// Pending, and an earlier pass has said so. - Again, -} - -pub struct DumpRequest { - word: AtomicU8, -} - -impl DumpRequest { - /// Must stay `const`: the kernel seeds it as a `static`. - #[cfg(not(feature = "loom"))] - pub const fn new() -> Self { - Self { word: AtomicU8::new(NONE) } - } - - // No `Default`: `Default::default` can't be `const`, unlike the arm above. - #[allow(clippy::new_without_default)] - #[cfg(feature = "loom")] - pub fn new() -> Self { - Self { word: AtomicU8::new(NONE) } - } - - /// One atomic update: `next` answers the word that replaces the one it is shown, or refuses it. The - /// compare-exchange judges the word as it is, so a refusal alone can rest on a stale read. - fn update(&self, set: Ordering, read: Ordering, next: impl Fn(u8) -> Option) -> Result { - let mut word = self.word.load(read); - loop { - let Some(new) = next(word) else { return Err(word) }; - match self.word.compare_exchange_weak(word, new, set, read) { - Ok(was) => return Ok(was), - Err(now) => word = now, - } - } - } - - /// Asked; asking while one is pending is that same request. - pub fn file(&self) { - let _ = self.update(Ordering::Relaxed, Ordering::Relaxed, |word| { - (word & PENDING == NONE).then_some(word | REQUESTED) - }); - } - - /// Whether a request is pending. A read, for the pass that has nothing to take. - pub fn pending(&self) -> bool { - self.word.load(Ordering::Relaxed) & PENDING != NONE - } - - /// Leave a pending request for a pass that may serve it. - pub fn leave(&self) -> Left { - let announced = self.update(Ordering::Relaxed, Ordering::Relaxed, |word| { - (word & PENDING == REQUESTED).then_some((word & !PENDING) | ANNOUNCED) - }); - match announced { - Ok(_) => Left::First, - Err(word) if word & PENDING == NONE => Left::Nothing, - Err(_) => Left::Again, - } - } - - /// Take a pending request and begin its report, unless a report runs: that report's end takes it. - pub fn take(&self) -> bool { - self.update(HANDOFF.0, HANDOFF.1, |word| { - (word & PENDING != NONE && word & REPORTING == 0).then_some(REPORTING) - }) - .is_ok() - } - - /// The report ended. `true` if a request was filed during it: this took it, and the caller reports again. - pub fn end_report(&self) -> bool { - let ended = self.update(HANDOFF.0, HANDOFF.1, |word| { - Some(if word & PENDING == NONE { NONE } else { REPORTING }) - }); - let (Ok(was) | Err(was)) = ended; - was & PENDING != NONE - } -} - -/// How the pass that met a request was entered: the whole of what decides whether it may serve. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Entered { - /// `driver::pass`, by the preempt depth it was entered at, before it raised its own level. - Pass { depth: u32 }, - /// `driver::pass_block`, inside a wait ticket's registration window at every depth. - Blocking, -} - -impl Entered { - /// Only a pass entered at depth zero has nothing under it; a syscall's pass has its trap frame and a - /// blocking pass its wait ticket, and a report served inside either panics on its own depth assertion. - pub fn may_serve(self) -> bool { - matches!(self, Self::Pass { depth: 0 }) - } -} - -impl core::fmt::Display for Entered { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Blocking => write!(f, "a blocking pass"), - Self::Pass { depth } => write!(f, "a pass entered at preempt depth {depth}"), - } - } -} diff --git a/kernel/src/sched/mod.rs b/kernel/src/sched/mod.rs index 0d152037da8..d53f39ee7a3 100644 --- a/kernel/src/sched/mod.rs +++ b/kernel/src/sched/mod.rs @@ -3,8 +3,6 @@ #![warn(clippy::undocumented_unsafe_blocks)] pub mod driver; -pub mod dump; -pub mod dump_request; pub mod kthread; pub mod payload; pub mod reap_gate; diff --git a/src/bootlog.rs b/src/bootlog.rs index 1f3e550e1b1..603b247640d 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -89,6 +89,16 @@ pub const USB_LOAD_SWEPT: &str = "usb-load: the sweep reached the end of the dis /// whatever the rest of the machine was doing. pub const LOCKED_UP: &str = "a cpu locked up with interrupts off"; +/// What the [`LOCKED_UP`] record says before the stuck CPU's `pc`, the `rip` +/// of the NMI frame that sampled it, in `kernel/src/hardlockup/mod.rs`. +pub const LOCKUP_PC: &str = " pc="; + +/// The function the hard-lockup control's CPU spins in, as that `pc` line +/// names it: `sync::Lock::lock`, whichever `Lock` the linker folded it +/// under. A line naming anything else is an NMI entry that handed the sample +/// the wrong word of its frame. +pub const LOCK_SPIN: [&str; 2] = [">::lock+"]; + /// What the kernel seals under its own `DONE` record, in /// `kernel/src/log/mod.rs`'s `seal_tail`: the head of the boot's newest /// records. The next loader pass prints it back under [`PREVIOUS_PANIC`]. @@ -708,6 +718,8 @@ mod tests { ("kernel/src/usb_gate.rs", format!("LOAD_STOPPED: &str = \"{USB_LOAD_STOPPED}\"")), ("kernel/src/usb_gate.rs", format!("LOAD_SWEPT: &str = \"{USB_LOAD_SWEPT}\"")), ("kernel/src/hardlockup/mod.rs", format!("LOCKED_UP: &str = \"{LOCKED_UP}\"")), + ("kernel/src/hardlockup/mod.rs", format!("\"{LOCKUP_PC}{{}}\", At(pc)")), + ("kernel/src/sync.rs", "pub fn lock(&self) -> LockGuard<'_, T>".to_string()), ( "kernel/src/drivers/panic_console/mod.rs", format!("CENSUS: &str = \"{PANEL_CENSUS}\""), diff --git a/src/ci.rs b/src/ci.rs index 72f0954c676..b5ac7380c8b 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -513,9 +513,6 @@ pub(crate) const CONTROLS: &[Control] = &[ red(KERNEL_LOOM, "device-irq-lossy", Some("device_irq"), &[ Fails("every_message_is_counted_once"), ]), - red(KERNEL_LOOM, "dump-report-relaxed", Some("dump_request"), &[ - Fails("a_request_filed_during_a_report_is_reported"), - ]), Control { krate: KERNEL_LOOM, feature: "no-preempt-guard", diff --git a/src/metal.rs b/src/metal.rs index 5e94fed3919..3bfb524af02 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -772,11 +772,6 @@ pub const FLASHABLE: &[&str] = &[ // hold, reaches no firmware state, and the worst // it leaves is a stick a replug clears — the defect the arm exists to stage. "usb-transport-break", - // It deafens one CPU for a window of its own clock and has the blocked-task - // dump kick it and probe it with an NMI. It reaches no device register and - // writes no firmware state; the CPU rejoins, and the boot goes on to - // userland and ends the way an unarmed one does. - "dump-deaf-cpu", // The kernel leaves the i8042 unprobed and a test process drives it through // an `isa` claim: it reads the configuration byte, has the keyboard // acknowledge `0xF4` and writes the byte back, each a command this kernel's diff --git a/src/qemu.rs b/src/qemu.rs index b23c16cb150..002c7782844 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -33,8 +33,7 @@ //! halted-awaiting-interrupt machine from a wedged one. //! //! **Take that capture before injecting anything.** A keystroke revives a -//! halted CPU, so Ctrl+Alt+D over the same socket both confirms the diagnosis -//! and destroys the evidence for it. +//! halted CPU, so input over the same socket destroys the evidence. //! //! # Audio //! diff --git a/src/sourcegate.rs b/src/sourcegate.rs index 9df06dc8134..f5f03b2e4f1 100644 --- a/src/sourcegate.rs +++ b/src/sourcegate.rs @@ -466,8 +466,7 @@ mod tests { /// The first: the NMI handler must not log. It would reenter its own CPU's /// log shard — the reservation is sound only because the CPU that owns the /// shard has `IF` and `TF` masked through publication, and an NMI is the one - /// interrupt that ignores `IF`. `dump_nmi_probe` is what makes the handler - /// *useful*; this is what keeps it silent. + /// interrupt that ignores `IF`. This is what keeps it silent. /// /// The second: no log producer in `kernel/` carries `!!!` in its format /// string. `panic_console::has_alert` used to scan every display row for diff --git a/tests/common/faults.rs b/tests/common/faults.rs index 4e83b9b2861..190a8d0cec2 100644 --- a/tests/common/faults.rs +++ b/tests/common/faults.rs @@ -70,73 +70,6 @@ fn whole(report: &[&str]) -> bool { && *last == STOPS } -/// The blocked-task dump's NMI probe: a CPU that ignores a kick is named, and -/// then asked where it is with the one interrupt it cannot mask. -/// -/// The verdict `no answer: it did not reach a scheduler pass` has three causes -/// — spinning with `IF` clear, halted with a lost kick, wedged below the -/// interrupt layer — and on the owner's T14 it named three CPUs without saying -/// which. The NMI separates them, so what this asserts is the separation: the -/// kick goes unanswered, the NMI is answered, and the `rip` it brings back -/// lands in the spin the actuator is executing. -/// -/// The last assertion is the one that keeps the instrument honest. A probe that -/// reported *some* address would satisfy every other line here; only resolving -/// it against the kernel's own symbols says the report points at where the CPU -/// actually was. -/// -/// **Judged on the T14 and in no QEMU guest.** The deafness is a window of the -/// actuator's own clock and the dump's kick and NMI budgets are the kernel's, -/// so a guest the host starves misses the window and reads exactly like the -/// defect this hunts. -pub fn dump_nmi_probe_on_metal(kernel: &Serial) -> Result<(), String> { - let log = kernel.text(); - if !log.contains("=== blocked-task dump:") { - return Err(format!("the dump never ran — is `dump-deaf-cpu` on?\n{log}")); - } - let silent: Vec<&str> = log - .lines() - .filter(|l| l.contains("no answer: it did not reach a scheduler pass")) - .collect(); - if silent.len() != 1 { - return Err(format!( - "expected exactly the deafened CPU to miss its kick, got {}:\n{}\n{log}", - silent.len(), - silent.join("\n"), - )); - } - if log.contains("no NMI answer either") { - return Err(format!( - "the NMI went unanswered too. The victim spins with IF clear and an NMI is not \ - maskable by IF, so this says the NMI never reached it at all — vector 2, the ICR \ - delivery mode, or the handler.\n{log}" - )); - } - let Some(rest) = log.split("NMI answered, it is here:\n").nth(1) else { - return Err(format!("the probe reported no rip for the silent CPU\n{log}")); - }; - let rip_line = rest.lines().next().unwrap_or(""); - if !rip_line.contains("deaf_window") { - return Err(format!( - "the rip resolved to `{}`, not to the spin the CPU was executing — a probe that \ - names the wrong instruction is worse than one that names none\n{log}", - rip_line.trim(), - )); - } - // And it comes back: an NMI interrupts, it does not kill. The witness has - // to be the victim's own line, printed after it re-enables interrupts. - // `Boot: complete` was the first attempt and is no witness at all — it is - // printed at 225 ms, ten seconds before this window opens, and by cpu0 into - // the boot log this drain does not even contain. - if !log.contains("rejoined after") { - return Err(format!( - "the deafened CPU never said it was back — an NMI must interrupt a CPU, not kill \ - it\n{log}" - )); - } - Ok(()) -} - /// Where `test_rs_fault_gate_child`'s kernel arms aim: the direct map's first /// words, which no process may name. const KERNEL_RSP: &str = "0xffff800000000000"; diff --git a/tests/common/irqcensus.rs b/tests/common/irqcensus.rs index bf45c5a80a7..7dc7f901412 100644 --- a/tests/common/irqcensus.rs +++ b/tests/common/irqcensus.rs @@ -3,7 +3,7 @@ //! //! The kernel says `irq: cpuN timer=… kick=… …` per online CPU where the //! machine ends, as records at its stop and in the record its death seals -//! (`kernel/src/census.rs`), and in the blocked-task dump; at no process's end. The counters are cumulative since boot, so the +//! (`kernel/src/census.rs`); at no process's end. The counters are cumulative since boot, so the //! largest count each source reaches on a CPU's lines is that boot's whole //! census ([`Census::raise`]). `irq_census_conservation` asks whether one //! boot's census is internally consistent. @@ -84,8 +84,7 @@ impl Census { /// Raise each source to its count in `read`, another line of this CPU. /// /// **Lines are in no read order.** The stop's census comes back on the - /// black-box page newest first, and a blocked-task dump reads the counters - /// before `log::emit` stamps its lines. The counters are monotonic, so the + /// black-box page newest first. The counters are monotonic, so the /// largest count per source is the newest read whatever the order of the /// lines. pub fn raise(&mut self, read: &Self) { diff --git a/tests/common/power.rs b/tests/common/power.rs index c9c90433fd7..7ffbbea66a0 100644 --- a/tests/common/power.rs +++ b/tests/common/power.rs @@ -555,6 +555,13 @@ pub fn hard_lockup_chain( let said = after.must_say_after(bootlog::PREVIOUS_PANIC, bootlog::LOCKED_UP)?.to_string(); let stuck = after.must_say_after(bootlog::PREVIOUS_PANIC, "spinning on the lock at 0x")?; sp_is_a_kernel_stack(stuck)?; + // And the NMI frame's `rip` is where that CPU stood: inside the lock's + // spin, resolved by the kernel against its own symbols. A `pc` read off + // any other word of the frame resolves to nothing, or to somewhere else. + let pc = after.must_say_after(bootlog::LOCKED_UP, bootlog::LOCKUP_PC)?; + if !bootlog::LOCK_SPIN.iter().all(|part| pc.contains(part)) { + return Err(format!("the record puts the stuck cpu outside the lock's spin {:?}: {pc:?}", bootlog::LOCK_SPIN)); + } // The staged control's own witness, carried by the mechanism rather than by // a log line that may not survive: the lock the stuck cpu is inside was // taken at the control's own source line, which no other boot can say. diff --git a/tests/metal/lenovo-20w0003amz.toml b/tests/metal/lenovo-20w0003amz.toml index b846dd8239a..7ee0d4bfe2b 100644 --- a/tests/metal/lenovo-20w0003amz.toml +++ b/tests/metal/lenovo-20w0003amz.toml @@ -37,9 +37,6 @@ bios = "N34ET71W (1.71 )" "boot.shared-debug.complete_ms" = 1165 "boot.shared-debug.panel_max_us" = 3791 "boot.shared-debug.panel_us" = 21483 -"boot.testcases-deaf.complete_ms" = 1165 -"boot.testcases-deaf.panel_max_us" = 5247 -"boot.testcases-deaf.panel_us" = 26659 "boot.testcases-mkdir.complete_ms" = 1166 "boot.testcases-mkdir.panel_max_us" = 3839 "boot.testcases-mkdir.panel_us" = 22100 diff --git a/tests/toyos-rust-tests/src/bin/lan_hold.rs b/tests/toyos-rust-tests/src/bin/lan_hold.rs deleted file mode 100644 index f998e4aedba..00000000000 --- a/tests/toyos-rust-tests/src/bin/lan_hold.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Hold the boot open, and exit. It asserts nothing; `dump_nmi_probe`'s metal -//! row judges the dump its actuator stages inside this window. - -use std::thread::sleep; -use std::time::Duration; - -const HOLD: Duration = Duration::from_millis(toyos_tco::LEASE_BOUND_MS); - -fn main() { - sleep(HOLD); -} diff --git a/tests/toyos.rs b/tests/toyos.rs index d795f879202..bee53692c05 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -154,9 +154,6 @@ const RUST_SKIP: &[&str] = &[ // It needs a host peer that ends each stream as the stream asks: // `libc_sockets` runs it on `tests/netcase`. "stream_ends_std", - // It asserts nothing at all: it holds `dump_nmi_probe`'s boot open for - // twenty seconds. On a shared boot it would be twenty seconds of nothing. - "lan_hold", // Fills /tmp to the VFS listing limit, so it needs a boot nothing else // shares — every later `read_dir("/tmp")` in it would be refused. // `readdir_bound` gives it one. @@ -243,7 +240,7 @@ const EARLY_PANIC_MESSAGE: &str = "test-early-panic: on-screen console check"; // the only one that architecture has. const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("screen_panic_muted", qemu::Profile::Metal), - // The same fatal path from inside Ctrl+Alt+D's report painter, holding the + // The same fatal path from inside a boot checkpoint's painter, holding the // panel's latch it will never give back: the report has to take the screen // anyway, and its CPU has to go on to watch the reset bound. The profile // whose 16550 is the console, where the fatal path writes its last line raw. @@ -607,22 +604,6 @@ const METAL: &[(&str, metal::Metal)] = &[ }, }, ), - ( - // One CPU deafened by the actuator, named by the blocked-task dump and - // found by its NMI where it spins. - "dump_nmi_probe", - metal::Metal { - // Held open by a job, because an empty list ends the boot before the - // actuator arms. - arms: &[metal::once( - "testcases-deaf", - "tests/testcases", - &["dump-deaf-cpu"], - &["test_rs_lan_hold"], - )], - judge: |b| faults::dump_nmi_probe_on_metal(&b[0].kernel()), - }, - ), ( // A fed watchdog: the armed boot runs its whole list to its own stop, // which a chipset reset anywhere in it would have cut short. @@ -2791,8 +2772,8 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "screen_fatal_behind_a_painter" => { // The fatal halt with a painter holding the panel's latch and // never giving it back — which is what a painter is when the halt - // IPI lands mid-paint. The actuator has Ctrl+Alt+D's report painter - // go fatal once it holds the latch, so the fatal path meets a + // IPI lands mid-paint. The actuator has the last boot checkpoint's + // painter go fatal once it holds the latch, so the fatal path meets a // holder beneath itself; the report must take the screen // regardless, and its CPU must go on to watch the reset bound, // which is what the reset proves. @@ -2808,20 +2789,11 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re profile, qmp: true, kernel_params: &["panel-painter-stalls", "panic-reboot-fast"], + // The fatal path is the boot's own end: it never reaches userland. + ready_marker: HELD, ..Default::default() }, ); - { - let mut input = qemu::QmpInput::open(qemu.qmp_socket()); - input.keys(&[ - ("ctrl", true), - ("alt", true), - ("d", true), - ("d", false), - ("alt", false), - ("ctrl", false), - ]); - } let dump = qemu.screendump_until(HELD, Duration::from_secs(30)); let text = dump.text(); print_screen(name, &text); @@ -2941,7 +2913,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re } // The death's own census, which the seal writes as lines of its // own. Anchored at the line's start: the ring's tail under it can - // carry a blocked-task dump's `irq: cpu0`, behind a record's stamp. + // carry an `irq: cpu0` record, behind its stamp. for owed in ["irq: cpu0 ", "tlb: shootdowns="] { if !sealed.lines().any(|line| line.starts_with(owed)) { return Err(format!(