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
6 changes: 3 additions & 3 deletions issues/every-interrupt-lands-on-the-boot-cpu.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ the machine's, and every device shares it.
## Instrument, and the baseline (2026-08-22)

`kernel/src/irq_census.rs` counts every delivery per CPU per source in
`PerCpu`, one `add qword ptr gs:[<off>], 1` for the machine's total and one for
the source. `irq: cpuN total=… timer=… …` is printed per CPU beside the
`PerCpu`, one `add qword ptr gs:[<off>], 1` for the source; a CPU's total is
their sum. `irq: cpuN timer=… kick=… …` is printed per CPU beside the
process-exit census, on `SYS_SHUTDOWN` and on the blocked-task dump;
`common::irqcensus` aggregates every guest's newest line into the suite's own
summary, so a CI shard's log carries the number without `--nocapture`.
`irq_census_conservation` gates both the arithmetic and the present-state fact.
`irq_census_conservation` gates the present-state fact.

A guest that boots and runs no program reaches no process exit and prints no
census, which is why the reporting counts are short of the boots. Both columns
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
status: open
kind: defect
opened: 2026-10-04
---

# The `irq_census` judge reds on two exits' lines stamped in the other order from their reads

The judge `irq_census` (`tests/toyos.rs`) compares lines from different process
exits as if the capture listed them in the order their counters were read. It
does not. Each exit prints `irq_census::log_census` and then
`tlb::log_census` (`kernel/src/process.rs`), and nothing serialises two exits on
two CPUs. `log::emit` reads its arguments and formats them before it stamps the
record (`kernel/src/log/mod.rs`), each CPU writes its own shard, and
`log::read::drain_ordered` merges the shards by stamp. So exit X can read a
counter before exit Y and still be stamped after Y.

Two of the judge's checks then red with no kernel defect:

- **The per-source monotonic check.** X reads cpu1's census, then Y reads it
with one more `kick`. Y is stamped first. The capture shows cpu1's `kick`
going backwards.
- **The issuer checks on `tlb: shootdowns=`.** Y loads `ISSUED` and gets T1.
X then loads T2 > T1, swaps `REPORTED` to T2 and logs. Y's swap returns T2,
which is not T1, so Y logs `shootdowns=T1` after X's T2. The capture shows
`T2` then `T1`, which reds "the issuer census went backwards". The last
`tlb:` line is then T1, and a newer `irq:` line's `tlb` count can exceed it,
which reds "some path shoots down without being counted".

Neither has been seen red. The recorded red the same window produced was the
deleted total/sources check (#734), where the reads were within one line.

Owner: the harness, `irq_census` in `tests/toyos.rs`.

**Exit:** a host test feeds the judge two exits' `irq:` and `tlb:` lines in
stamp order, with the later stamp carrying the earlier read as above, and the
judge stays green.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: defect
opened: 2026-10-04
---

# The spurious and unclaimed selftests' "took interrupts after it" check cannot fail

`spurious::selftest` (`kernel/src/arch/x86_64/idt/spurious.rs`) and
`unclaimed::selftest` (`kernel/src/arch/x86_64/idt/unclaimed.rs`) each claim to
confirm the CPU still takes interrupts after the probe vector. They read
`taken_before = deliveries_total(cpu)` before `apic::send_self`. The probe's
own delivery is counted in that total. Once `delivered` has held, the probe's
source count is past `before`, so `deliveries_total(cpu) > taken_before` is
already true at the first poll. A CPU left deaf by the probe's handler passes
the check, and the `3/3` line says "the CPU took interrupts after it" either
way.

Owner: the LAPIC selftests, `kernel/src/arch/x86_64/idt/`.

**Exit:** a mutation that leaves the CPU taking no interrupt once the probe's
handler returns makes each selftest print `FAILED`, and the `selftests` row in
`tests/toyos.rs` that boots both actuators reds on it.
15 changes: 7 additions & 8 deletions kernel/src/arch/aarch64/percpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ pub struct PerCpu {
syscall_task: AtomicU64,
/// This CPU's [`log::Shard`]: the boot shard on CPU 0.
log_shard: &'static log::Shard,
/// One counter per `irq_census::Source`, and the total.
irq_counts: [AtomicU64; crate::irq_census::SLOTS],
/// One counter per `irq_census::Source`.
irq_counts: [AtomicU64; crate::irq_census::Source::COUNT],
}

/// This CPU's block.
Expand Down Expand Up @@ -94,7 +94,7 @@ pub fn alloc(cpu_id: u32) -> &'static PerCpu {
syscall_sp: AtomicU64::new(0),
syscall_task: AtomicU64::new(NO_SYSCALL),
log_shard: log::shard_for(cpu_id),
irq_counts: [const { AtomicU64::new(0) }; crate::irq_census::SLOTS],
irq_counts: [const { AtomicU64::new(0) }; crate::irq_census::Source::COUNT],
}));
crate::irq_census::publish(cpu_id, block.irq_counts.as_ptr());
block
Expand Down Expand Up @@ -249,14 +249,13 @@ pub fn reserve_log_slot(guard: &crate::arch::IrqGuard) -> (*const log::Shard, u6
/// One delivery of `source`, counted in this CPU's block.
pub(super) fn irq_took(source: crate::irq_census::Source) {
let counts = &this().irq_counts;
counts[crate::irq_census::TOTAL].fetch_add(1, Relaxed);
counts[1 + source as usize].fetch_add(1, Relaxed);
counts[source as usize].fetch_add(1, Relaxed);
}

/// Two of this CPU's interrupt counters.
pub fn irq_counts_here(first: usize, second: usize) -> (u64, u64) {
/// This CPU's interrupt counters.
pub fn irq_counts_here() -> [u64; crate::irq_census::Source::COUNT] {
let counts = &this().irq_counts;
(counts[first].load(Relaxed), counts[second].load(Relaxed))
core::array::from_fn(|index| counts[index].load(Relaxed))
}

#[inline]
Expand Down
8 changes: 2 additions & 6 deletions kernel/src/arch/x86_64/idt/timer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ pub(super) extern "sysv64" fn timer_entry() {
"iretq",

"2:",
// No Rust half on this branch, so these two `add`s inline what `timer_handler` does for Ring 3; flags are dead after the `test` above, so none are saved.
"add qword ptr gs:[{irq_total}], 1",
// No Rust half on this branch, so this `add` inlines what `timer_handler` does for Ring 3; flags are dead after the `test` above, so none are saved.
"add qword ptr gs:[{irq_timer}], 1",
"push rax",
"push rcx",
Expand Down Expand Up @@ -92,10 +91,7 @@ pub(super) extern "sysv64" fn timer_entry() {
quantum_ticks = sym crate::arch::apic::TIMER_TICKS,
need_resched = const crate::arch::percpu::OFF_NEED_RESCHED,
ring0_fires = const crate::arch::percpu::OFF_RING0_TIMER_FIRES,
irq_total = const crate::arch::percpu::irq_slot_offset(crate::irq_census::TOTAL),
irq_timer = const crate::arch::percpu::irq_slot_offset(
1 + crate::irq_census::Source::Timer as usize
),
irq_timer = const crate::arch::percpu::irq_slot_offset(crate::irq_census::Source::Timer as usize),
);
}

Expand Down
54 changes: 25 additions & 29 deletions kernel/src/arch/x86_64/percpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ pub struct PerCpu {
/// Non-zero inside this CPU's NMI handler, written only by `arch::idt::nmi`'s entry; IST2 isn't re-entrant, so this proves no second NMI lands on it.
nmi_active: u32,
ap_token: u32,
/// Interrupt deliveries, one counter per `irq_census::Source`; written only by `irq_census::irq_took!`, kept last so growing `SLOTS` moves nothing else.
pub irq_counts: [AtomicU64; crate::irq_census::SLOTS],
/// Interrupt deliveries, one counter per `irq_census::Source`; written only by `irq_census::irq_took!`, kept last so a new source moves nothing else.
pub irq_counts: [AtomicU64; crate::irq_census::Source::COUNT],
}

const GDT_ENTRIES: [u64; 7] = [
Expand Down Expand Up @@ -374,7 +374,7 @@ fn alloc_percpu(cpu_id: u32) -> *mut PerCpu {
log_shard: log::shard_for(cpu_id) as *const log::Shard as u64,
nmi_active: 0,
ap_token: 0,
irq_counts: [const { AtomicU64::new(0) }; crate::irq_census::SLOTS],
irq_counts: [const { AtomicU64::new(0) }; crate::irq_census::Source::COUNT],
},
);
}
Expand Down Expand Up @@ -725,20 +725,18 @@ pub const fn irq_slot_offset(index: usize) -> u32 {
OFF_IRQ_COUNTS + (index as u32) * 8
}

/// Records one delivery of `$source` as two lock-free `add`s to this CPU's own gs: slots.
/// A macro, not a function: the two offsets must be asm immediates, not const-generic values an optimiser could relax.
/// Records one delivery of `$source` as one lock-free `add` to this CPU's own gs: slot.
/// A macro, not a function: the offset must be an asm immediate, not a const-generic value an optimiser could relax.
macro_rules! irq_took {
($source:ident) => {{
// SAFETY: both slots are this CPU's own counter block per `arch::percpu`, and the caller is an interrupt handler, so `GS_BASE` already points at this CPU's `PerCpu`.
// SAFETY: the slot is this CPU's own counter block per `arch::percpu`, and the caller is an interrupt handler, so `GS_BASE` already points at this CPU's `PerCpu`.
unsafe {
::core::arch::asm!(
"add qword ptr gs:[{total}], 1",
"add qword ptr gs:[{source}], 1",
total = const $crate::arch::percpu::irq_slot_offset($crate::irq_census::TOTAL),
source = const $crate::arch::percpu::irq_slot_offset(
1 + $crate::irq_census::Source::$source as usize
$crate::irq_census::Source::$source as usize
),
// no `nomem` because both instructions write; no `preserves_flags` because `add` clobbers flags.
// no `nomem` because the instruction writes; no `preserves_flags` because `add` clobbers flags.
options(nostack),
);
}
Expand All @@ -747,26 +745,24 @@ macro_rules! irq_took {

pub(crate) use irq_took;

/// Two of this CPU's interrupt counters, read straight off `gs:` with one load
/// This CPU's interrupt counters, read straight off `gs:` with one load
/// each — the form a CPU inside an NMI may use.
pub fn irq_counts_here(first: usize, second: usize) -> (u64, u64) {
let a: u64;
let b: u64;
// SAFETY: both slots are this CPU's own counter block, and `GS_BASE` points
// at the running CPU's `PerCpu` in every context this is read from; both
// indices are below `irq_census::SLOTS`, asserted by the callers' constants.
unsafe {
core::arch::asm!(
"mov {a}, qword ptr gs:[{first}]",
"mov {b}, qword ptr gs:[{second}]",
a = out(reg) a,
b = out(reg) b,
first = in(reg) u64::from(irq_slot_offset(first)),
second = in(reg) u64::from(irq_slot_offset(second)),
options(nostack, readonly, preserves_flags),
);
}
(a, b)
pub fn irq_counts_here() -> [u64; crate::irq_census::Source::COUNT] {
core::array::from_fn(|index| {
let count: u64;
// SAFETY: the slot is this CPU's own counter block, and `GS_BASE` points
// at the running CPU's `PerCpu` in every context this is read from;
// `from_fn` keeps `index` below the block's length.
unsafe {
core::arch::asm!(
"mov {count}, qword ptr gs:[{at}]",
count = out(reg) count,
at = in(reg) u64::from(irq_slot_offset(index)),
options(nostack, readonly, preserves_flags),
);
}
count
})
}

/// This CPU's preempt count: the per-CPU word `crate::preempt` keeps, read and
Expand Down
36 changes: 15 additions & 21 deletions kernel/src/irq_census.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Per-CPU, per-source interrupt delivery counts, kept in `PerCpu` for a lock-free `add`.
//! `total` increments separately from the per-source counts, so a missing source
//! increment shows as `total` exceeding their sum; `irq_census_conservation` checks it.
//! One word per delivery and no total beside it: a CPU's total is the sum of its sources,
//! because a second word another CPU reads between the two `add`s is a census that does not add up.
//! Device interrupts land on the boot CPU only; the timer and shootdown IPI are per-CPU.

use core::fmt;
Expand Down Expand Up @@ -56,11 +56,10 @@ impl Source {
];
}

/// One `u64` per source plus the total, in each CPU's own per-CPU block.
pub const SLOTS: usize = 1 + Source::COUNT;

/// Index of the machine's own total inside a CPU's block.
pub const TOTAL: usize = 0;
/// Every source's count summed, bar `except`'s.
fn sum_except(counts: &[u64; Source::COUNT], except: Option<Source>) -> u64 {
counts.iter().sum::<u64>() - except.map_or(0, |source| counts[source as usize])
}

// Counted where each is taken, by the architecture's handlers
// (`arch::percpu::irq_took!`), into this CPU's own block.
Expand All @@ -77,12 +76,12 @@ pub(crate) fn publish(cpu_id: u32, block: *const AtomicU64) {
}

/// One CPU's counters, or `None` if that CPU has never been built.
fn read(cpu: u32) -> Option<[u64; SLOTS]> {
fn read(cpu: u32) -> Option<[u64; Source::COUNT]> {
let base = BLOCKS.get(cpu as usize)?.load(Ordering::Acquire) as *const AtomicU64;
if base.is_null() {
return None;
}
let mut out = [0u64; SLOTS];
let mut out = [0u64; Source::COUNT];
for (i, slot) in out.iter_mut().enumerate() {
// SAFETY: `base.add(i)` points at a live, in-bounds, single-writer counter word.
*slot = unsafe { (*base.add(i)).load(Ordering::Relaxed) };
Expand All @@ -104,13 +103,13 @@ impl fmt::Display for Fields<'_> {

/// One CPU's delivery count for `source`, or `None` if that CPU has never been built.
pub fn deliveries(cpu: u32, source: Source) -> Option<u64> {
read(cpu).map(|counts| counts[1 + source as usize])
read(cpu).map(|counts| counts[source as usize])
}

/// Every CPU's total delivery count, or `None` if that CPU has never been built.
#[cfg(feature = "boot-actuators")]
pub fn deliveries_total(cpu: u32) -> Option<u64> {
read(cpu).map(|counts| counts[TOTAL])
read(cpu).map(|counts| sum_except(&counts, None))
}

/// **One CPU's progress: every interrupt it has taken except the NMI.**
Expand All @@ -120,31 +119,26 @@ pub fn deliveries_total(cpu: u32) -> Option<u64> {
/// *as* an NMI and has already counted itself by the time it reads this, so a
/// total including it moves on every sample and no CPU is ever stuck.
///
/// One published pointer and two relaxed loads: no lock, so the CPU sealing a
/// One published pointer and relaxed loads: no lock, so the CPU sealing a
/// record about a sibling can read this about it.
pub fn taken_by(cpu: u32) -> Option<u64> {
read(cpu).map(|counts| counts[TOTAL].saturating_sub(counts[1 + Source::Nmi as usize]))
read(cpu).map(|counts| sum_except(&counts, Some(Source::Nmi)))
}

/// [`taken_by`] for the CPU asking, read straight off its own block — the one
/// form a CPU inside an NMI may use, since it needs neither the published
/// pointer array nor a bounds check on a `cpu_id` it is standing on.
pub fn taken_here() -> u64 {
let (total, nmis) = percpu::irq_counts_here(TOTAL, 1 + Source::Nmi as usize);
total.saturating_sub(nmis)
sum_except(&percpu::irq_counts_here(), Some(Source::Nmi))
}

/// Logs one `irq: cpuN total=… <source>=…` line per online CPU; counts are cumulative since boot.
/// Logs one `irq: cpuN <source>=…` line per online CPU; counts are cumulative since boot.
/// A `mask-windows` kernel follows each with that CPU's `windows:` line.
/// Allocates nothing, takes no lock, touches no device.
pub fn log_census() {
for cpu in 0..crate::smp::cpu_count() {
let Some(counts) = read(cpu) else { continue };
crate::log!(
"irq: cpu{cpu} total={}{}",
counts[TOTAL],
Fields(&counts[TOTAL + 1..])
);
crate::log!("irq: cpu{cpu}{}", Fields(&counts));
#[cfg(feature = "mask-windows")]
crate::windows::log_cpu(cpu);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ mod checks {
use common::irqcensus::{windows_under, Measured};
let census = |cpu: u32| {
format!(
"[kernel 0.1 cpu0] irq: cpu{cpu} total=0 timer=0 kick=0 xhci=0 userdev=0 sound=0 i8042=0 \
"[kernel 0.1 cpu0] irq: cpu{cpu} timer=0 kick=0 xhci=0 userdev=0 sound=0 i8042=0 \
dmafault=0 hda=0 tlb=0 nmi=0 spurious=0 unclaimed=0\n"
)
};
Expand Down
22 changes: 9 additions & 13 deletions tests/common/irqcensus.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! The kernel's interrupt census, and the windows a `mask-windows` kernel
//! prints beside it, read back on the host.
//!
//! The guest prints `irq: cpuN total=… timer=… …` per online CPU whenever a
//! The guest prints `irq: cpuN timer=… kick=… …` per online CPU whenever a
//! process exits, on `SYS_SHUTDOWN` and on the blocked-task dump
//! (`kernel/src/irq_census.rs`). The counters are cumulative since boot, so the
//! **last** line a capture holds for a CPU is that boot's whole census.
Expand Down Expand Up @@ -39,7 +39,6 @@ pub const DEVICE_SOURCES: [&str; 6] = ["xhci", "userdev", "sound", "i8042", "dma
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Census {
pub cpu: u32,
pub total: u64,
/// Indexed the same as [`SOURCES`].
pub by_source: [u64; SOURCES.len()],
}
Expand Down Expand Up @@ -71,30 +70,27 @@ impl Census {
.map_err(|_| format!("field {field:?} has no count in {rest:?}"))?;
named.push((name, value));
}
let want: Vec<&str> = std::iter::once("total").chain(SOURCES).collect();
let got: Vec<&str> = named.iter().map(|(n, _)| *n).collect();
if got != want {
if got != SOURCES {
return Err(format!(
"census fields {got:?}, want {want:?} — the kernel's `Source::NAMES` and \
"census fields {got:?}, want {SOURCES:?} — the kernel's `Source::NAMES` and \
`common::irqcensus::SOURCES` disagree"
));
}
let mut by_source = [0u64; SOURCES.len()];
for (slot, (_, value)) in by_source.iter_mut().zip(&named[1..]) {
for (slot, (_, value)) in by_source.iter_mut().zip(&named) {
*slot = *value;
}
Ok(Self { cpu, total: named[0].1, by_source })
Ok(Self { cpu, by_source })
}

pub fn source(&self, name: &str) -> u64 {
let i = SOURCES.iter().position(|s| *s == name).expect("no such census source");
self.by_source[i]
}

/// Every source summed. Equal to [`Self::total`] on a census that adds up —
/// which is the whole of what `irq_census_conservation` asks, because the
/// kernel counts the total apart from the sources rather than deriving it.
pub fn sum_of_sources(&self) -> u64 {
/// Every interrupt this CPU took: the kernel keeps no total apart from its sources.
pub fn total(&self) -> u64 {
self.by_source.iter().sum()
}
}
Expand Down Expand Up @@ -326,11 +322,11 @@ fn guests() -> Vec<Guest> {
let seen = SEEN.lock().expect("census map poisoned");
seen.values()
.filter_map(|by_cpu| {
let total: u64 = by_cpu.values().map(|c| c.total).sum();
let total: u64 = by_cpu.values().map(Census::total).sum();
if total == 0 {
return None;
}
let boot = by_cpu.get(&0).map_or(0, |c| c.total);
let boot = by_cpu.get(&0).map_or(0, Census::total);
let mut per_source = [(0u64, 0u64); SOURCES.len()];
for census in by_cpu.values() {
for (slot, count) in per_source.iter_mut().zip(census.by_source) {
Expand Down
Loading
Loading