Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,9 @@ pub(crate) const CONTROLS: &[Control] = &[
red(KERNEL_LOOM, "device-irq-lossy", Some("device_irq"), &[
Fails("every_message_is_counted_once"),
]),
red(KERNEL_LOOM, "device-irq-gate-intent", Some("device_irq_gate"), &[
Fails("hardware_ends_as_the_record_owes_after_two_messages"),
]),
Control {
krate: KERNEL_LOOM,
feature: "no-preempt-guard",
Expand Down
49 changes: 26 additions & 23 deletions issues/a-claimed-function-can-storm-its-cpu.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,31 @@ opened: 2026-10-04

# A claimed function can storm its CPU

A function a process drives raises its claim's interrupt on cpu0 for
every message it sends — on x86-64 the claim's vector
(`kernel/src/arch/x86_64/idt/user_dev.rs`), on AArch64 the function's LPI through
the ITS (`kernel/src/arch/aarch64/irqchip/its.rs`) — and `pcidev::isr`
(`kernel/src/pcidev/mod.rs`) takes each one; nothing bounds how often. Its
holder programs the device through the BARs it maps, so a hostile or broken
holder can make the device send messages as fast as the bus carries
them, and each one is an interrupt cpu0 takes before anything else it runs.
The kernel's own xHCI measured what that does to a CPU for a kernel
driver's source, ten timer interrupts in thirty-four seconds on T14 run 20,
before that driver left the kernel; a claim is the same source with its
holder outside the kernel, which every xHCI controller now is.
A claim's message is masked from the second message that lands before its
holder reads the record until that read (`kernel/src/pcidev/record.rs`), so
what lands between two reads stops at what arrives before the mask takes.
That bounds it per read and not per unit of time, and three things remain.

The lever is the claim's own translation: on x86-64 its remapping entry, which
made not present stops the function's messages at the unit; on AArch64 its
LPI's configuration byte, which cleared and followed by an `INV` disables
the LPI at the redistributor. Masking through the device would need the
holder's cooperation. What rate is a storm is not decided, and a number
chosen without a measurement behind it is the silent decision this file
exists to refuse.
**A holder paces cpu0.** A holder that loops on forcing a cause through its
own registers and reading its record, on any CPU, still raises one cpu0
interrupt per iteration, charged to whatever cpu0 interrupted and not to the
holder. One that forces two per read also costs cpu0 an invalidation
completion wait with interrupts off at the mask (VT-d's interrupt-entry-cache
invalidation, AMD-Vi's completion wait, or the ITS's `SYNC`); the read's own
wait runs in the holder's call, and reaches cpu0 only as a spin on the unit's
queue behind it.

Owner: the IOMMU track. Exit: a claimed function's messages past a bound the
kernel derives and states are stopped at its entry and recorded against its
claim, and a T14 row with a holder that provokes them shows cpu0's timer and
other sources still served.
**A refused message is recorded nowhere.** While masked, a message is target
aborted (AMD-Vi) or blocked with fault processing disabled (VT-d), and
neither the unit nor the claim's record counts it. On AArch64 a disabled LPI
stays pending and folds into one.

**No T14 row shows cpu0's other sources served under a holder that
provokes its function.** `i219_msi`'s storm phase shows a forced storm on
an unread claim stopped at the entry, and not the timer.

Owner: the IOMMU track. Exit: cpu0's interrupts from a claim are bounded in
time, and the work they cost is charged to their holder, by a bound the
kernel derives and states; a message refused past it is counted against
the claim; and a T14 row with a holder that loops force-and-read shows
cpu0's timer and other sources still served.
27 changes: 27 additions & 0 deletions issues/a-read-into-a-claims-bar-is-not-refused-on-aarch64.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
status: open
kind: defect
opened: 2026-11-06
---

# A read into a claim's BAR is not refused on AArch64

`virt_claim_lpi`'s `claim_bar_fault` job (`tests/guest/rust-tests/src/bin/claim_bar_fault.rs`)
reads a pipe into a slice that points at its claim's mapped BAR registers and
expects `Err(BadAddress)`. On main at 0b2dec674 the read answered `Ok(16)`,
and the job ended at line 100: "a read into the BAR was not refused
BadAddress", left `Ok(16)`, right `Err(BadAddress)`.

Seen on the guest runner's aarch64 row, both at main's own commit (run
42124293522, branch `storm-main-check`) and at #1193's rebased head (run
42120673220); the same row passed at #1193's heads before the rebase, on
#1188's base (runs 42035960983, 42077063953). Between the two bases main
took, among others, #1150, which routes every user copy through the process's
own mapping. Which change made the read succeed, and whether the kernel or
the test's expectation is now wrong, is not established.

Evidence: https://github.com/ToyOSOrg/evidence/tree/05c9ce10c698b6618a8ac9c5f38f41fe1e2451a0/pr-1193/r4/logs

Owner: the memory track (S0-a). Exit: a read whose destination is a claim's
BAR is refused as the kernel's copy contract states, or the contract and the
test say otherwise, and `virt_claim_lpi` passes on main.
12 changes: 12 additions & 0 deletions issues/the-intel-nics-room-and-wake-are-unread-on-hardware.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ write-back policy this path's.
What either part does with its ring across a link change is not in this
exit: `issues/no-machine-has-read-an-intel-nic-across-a-link-change.md`.

**Sighting, 2026-11-05: `i219_msi`'s burst does not fill the T14's ring
either.** Its traffic phase queues `4 * TX_RING` frames back to back and
then requires `tx_full` and a taken wake, and on the bench's link the wire
sends them faster than the driver queues them: "the ring full 0 time(s), 0
wake(s) asked and 0 taken, 0 stranded; the MAC counted 65 sent", both on
#1188's T14 run, where the job failed first on its receive check
(https://github.com/ToyOSOrg/evidence/blob/72d7fa82557c6680fb33008665e717ddc83cb849/pr-1188/r3/t14-kernel-excerpt.log,
line 43), and on #1193's B arm, where it is the job's failure
(https://github.com/ToyOSOrg/evidence/tree/3bfbd7de150de5686df1edc749efc33b07722354/pr-1193/r1).
That phase's `tx_full` requirement is this exit's premise and the burst does
not establish it.

## Owner

The T14's outbound rows of `issues/toyos-has-its-own-network-stack.md`: the
Expand Down
26 changes: 26 additions & 0 deletions issues/the-sysroot-pin-test-refused-a-move-it-expects-to-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
status: open
kind: tooling
opened: 2026-11-05
---

# The sysroot pin test refused a move it expects to make, once

`sysroot::tests::the_primary_s_fork_checkout_is_moved_to_its_pin`
(`build/sysroot.rs`) redded once in a `cargo run -- --ci host` run at
ce5d8469d, on a host other builds were loading, and passed run alone at the
same revision.

What the log shows: after three moves of the test's own fork checkout
(0a69 to 3a47, back, and to 3a47 again), the fourth, the test's `pin(&c1)`
before its deliberate edit, refused with "is at 3a47… with uncommitted work,
and this tree pins the fork at 0a69…", naming one entry,
`1 .M N... 100644 100644 100644 82a3da18… 82a3da18… library/std/src/lib.rs`.
The test had written nothing to that file before the refusal. Why `git
status` reported it modified is not established.

Evidence: #1193's host CI log at ce5d8469d and the test alone passing there:
https://github.com/ToyOSOrg/evidence/tree/38c6ab9b31d7e7eff1b3ffc9be1180c3130f29c7/pr-1193/r3/logs

Owner: the build track. Exit: the cause found and fixed, or the test shown to
hold under the load it redded in.
4 changes: 4 additions & 0 deletions kernel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ 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/pcidev/record.rs`'s gate follows each side's intent instead of the
# record, so a mask landing after the read that opened it strands the claim, and
# `device_irq_gate` reds.
device-irq-gate-intent = []
# The same arrangement for the CPU roster's publication edge: `src/smp_roster.rs`'s
# `commit` publishes the count with a `Relaxed` store instead of the release the
# slot store needs, so a reader can see a count over an unfilled slot, and
Expand Down
10 changes: 10 additions & 0 deletions kernel/amdvi/src/irte.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//! entry is the message's own vector.

const REMAP_EN: u64 = 1 << 0;
/// `SupIOPF`: no `IO_PAGE_FAULT` is logged for a message through the entry,
/// whatever `RemapEn` says (Table 22).
const SUP_IOPF: u64 = 1 << 1;
const DESTINATION_SHIFT: u32 = 8;
/// `Vector`, bits 71:64: bits 7:0 of the second doubleword.
const VECTOR_SHIFT: u32 = 0;
Expand All @@ -30,6 +33,13 @@ pub const fn entry(vector: u8, destination: Destination) -> [u64; 2] {

pub const ABSENT: [u64; 2] = [0, 0];

/// `entry` refusing its messages silently: `RemapEn` clear, so each is target
/// aborted (Table 22, §2.1.3.1), and `SupIOPF` set, so none is logged. The
/// rest of the entry is kept for the [`entry`] it was to deliver again.
pub const fn masked(entry: [u64; 2]) -> [u64; 2] {
[entry[0] & !REMAP_EN | SUP_IOPF, entry[1]]
}

/// Whether an entry read back delivers anything.
pub const fn present(words: [u64; 2]) -> bool {
words[0] & REMAP_EN != 0
Expand Down
11 changes: 11 additions & 0 deletions kernel/amdvi/tests/encodings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,17 @@ fn a_remapping_entry_delivers_its_vector_to_its_destination() {
assert!(!irte::present(irte::ABSENT));
}

/// Table 22: a masked entry has `RemapEn` [0] clear and `SupIOPF` [1] set,
/// and every other bit of the entry it masks.
#[test]
fn a_masked_entry_aborts_silently_and_keeps_its_fields() {
let delivering = irte::entry(0xFB, Destination::Extended(0x1234_5678));
let masked = irte::masked(delivering);
assert_eq!(masked, [delivering[0] & !1 | 1 << 1, delivering[1]]);
assert!(!irte::present(masked));
assert_eq!((irte::vector(masked), irte::destination(masked)), (0xFB, 0x1234_5678));
}

/// Figure 48: `INVALIDATE_INTERRUPT_TABLE`, code 05h, the id at [15:0].
#[test]
fn invalidating_an_interrupt_table_names_its_requester() {
Expand Down
11 changes: 11 additions & 0 deletions kernel/loom/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ shard-publish-relaxed = []
# 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 a claimed function's interrupt gate. The ISR masks
# and the read opens by their own intent, not by what the record owes, so a
# mask that lands after the read opened the gate strands the claim, and
# `device_irq_gate.rs` must red:
#
# cargo test --manifest-path kernel/loom/Cargo.toml --features device-irq-gate-intent \
# --test device_irq_gate
#
# Never on by default and never reachable from a kernel build, which declares
# the same name only so `cfg` checking knows it.
device-irq-gate-intent = []
# The negative control for a program's log ring, `sdk/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
Expand Down
6 changes: 3 additions & 3 deletions kernel/loom/tests/device_irq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,12 @@ fn an_idle_record_answers_nothing() {
loom::model(|| {
let irq = Interrupt::new();
assert!(irq.take().is_none(), "an untouched record answered a reader");
assert!(!irq.armed(), "an untouched record read ready");
assert!(!irq.pending(), "an untouched record read ready");
irq.took(7);
assert!(irq.armed(), "a record with a message in it read empty");
assert!(irq.pending(), "a record with a message in it read empty");
let record = irq.take().expect("a record with a message in it answered nothing");
assert_eq!((record.count, record.first_nanos, record.last_nanos), (1, 7, 7));
assert!(irq.take().is_none(), "the same message was answered twice");
assert!(!irq.armed(), "a drained record still reads ready");
assert!(!irq.pending(), "a drained record still reads ready");
});
}
64 changes: 64 additions & 0 deletions kernel/loom/tests/device_irq_gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Loom: a claimed function's interrupt ends masked exactly while its record
//! owes a mask, from the second unread message, however the handler and a
//! read interleave (`pcidev/record.rs`); the `device-irq-gate-intent` control
//! must red the two-message model.

use kernel_loom::device_irq::{Gate, Interrupt};
use loom::sync::{Arc, Mutex};

/// The claim's interrupt as hardware has it, behind the gate's lock.
struct Line(Mutex<bool>);

impl Gate for Line {
fn hold(&self, masked: impl FnOnce() -> bool) {
let mut hardware = self.0.lock().unwrap();
*hardware = masked();
}
}

/// Messages against one read that may run at any point among them, and a
/// last read once they have all landed: after each, hardware is masked
/// exactly while the record owes it.
fn model(messages: u64) {
loom::model(move || {
let irq = Arc::new(Interrupt::new());
let line = Arc::new(Line(Mutex::new(false)));

let isr = {
let (irq, line) = (irq.clone(), line.clone());
loom::thread::spawn(move || {
for at in 1..=messages {
irq.took_masking(at, &*line);
}
})
};

let taken = irq.take_opening(&*line).map_or(0, |record| u64::from(record.count));
isr.join().unwrap();
let masked = *line.0.lock().unwrap();
assert_eq!(
masked,
irq.owes_mask(),
"the read took {taken} of {messages} message(s) and hardware is {} where the record owes it {}",
if masked { "masked" } else { "open" },
if irq.owes_mask() { "masked" } else { "open" },
);

let left = irq.take_opening(&*line).map_or(0, |record| u64::from(record.count));
assert_eq!(taken + left, messages, "the reads took {taken} and {left} of {messages}");
assert!(!*line.0.lock().unwrap(), "every message was read and the claim is still masked");
});
}

/// One message per read never masks.
#[test]
fn one_message_per_read_never_masks() {
model(1);
}

/// A second message before a read masks, and the read that takes them opens
/// it, wherever the read lands among them.
#[test]
fn hardware_ends_as_the_record_owes_after_two_messages() {
model(2);
}
Loading