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
20 changes: 20 additions & 0 deletions issues/kernel/a-safe-mmio-window-reaches-any-physical-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# A safe `Mmio` window reaches any physical address

`Mmio::new` (`kernel/src/mm/mmio.rs`) is a safe `pub(crate)` constructor over
any `DirectMap`, `DirectMap::from_phys` (`kernel/src/mm/mod.rs`) is safe for
any address, and `Mmio::write_u32` is safe: any kernel module can write any
physical address, RAM included, with no `unsafe` to answer for. So no type
can make a device's registers its driver's alone: a module that builds a
window over `arch::console_uart::frame()` writes the PL011 without the
`serial::Registers` that `arch::console_uart::write_byte` asks for.

**Evidence:** the code. Its callers are `map_mmio` on both architectures, the
AArch64 GIC redistributor's window and the PL011's.

**Exit:** no safe call builds a window over memory it was not handed.
22 changes: 22 additions & 0 deletions issues/kernel/a-safe-port-read-reaches-any-io-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# A safe port read reaches any I/O port

`inb` and `inw` (`kernel/src/arch/x86_64/cpu.rs`) are safe `pub fn`s over
any port, "safe because a read has no value a caller can get wrong". A port
read has effects of its own: a read of COM1's data register takes the 16550's
received byte, a read of its line status clears the error bits, and a read of
the i8042's data port takes its output byte. So any kernel module takes a
received console byte with `crate::arch::cpu::inb(0x3f8)`, with no
`serial::Registers` and no `unsafe`, past the type that makes the UART's
registers their holder's alone. `outb` and `outw` are `unsafe`, under a
contract that the caller owns the port; the reads carry none.

**Evidence:** the code. `inb`'s callers are `arch::console_uart`, `arch::rtc`
and `arch::i8042`; `inw`'s is `arch::watchdog`.

**Exit:** no safe call reads a port it was not handed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# The AArch64 boot CPU's id read is held against nothing

`Roster::commit` (`kernel/src/smp_roster.rs`) holds every AP's `arch::cpu::hardware_id`
against the MADT MPIDR it was started by, and x86-64's `boot_aps` holds the
boot CPU's against its LAPIC id. AArch64's `smp::start` makes the boot CPU's
read its roster slot (`ROSTER.set_bsp(me)`), and `irqchip::init` picks the
boot CPU's redistributor by it, so nothing independent of the read holds it.
A read naming no MADT CPU panics in `Gic::redistributor`. One naming another
CPU brings that CPU's redistributor up as the boot CPU's and skips that CPU,
then sends `CPU_ON` to the boot CPU, which PSCI refuses as already on, so
every CPU after it in the MADT stays off.

**Evidence:** the code (`kernel/src/arch/aarch64/smp.rs`,
`kernel/src/arch/aarch64/irqchip.rs`).

**Exit:** the boot CPU's read is held, before the roster takes it, against an
id it did not produce.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# A fatal path inside its own CPU's console burst waits out the bound

A fatal path takes the console registers by `BackendLock::seize`
(`kernel/src/drivers/serial_lock.rs`), which finds a hold its own CPU's fatal
path left and gives up on any other after `PANIC_LOCK_SPIN_LIMIT` tries. A
burst holder — `uart_write_fifo`, virtio-console's publish and its looks, a
byte read — holds them as `LIVE`, which names no CPU. A fault, a panic or a
nested NMI that lands inside one waits the whole bound for a holder that never
runs again, says so, and writes over it; every later `panic_registers` on
that path waits it again.

**Evidence:** the code.

**Exit:** no fatal path waits out a hold its own CPU left.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# A panic reentry inside a fatal path's console hold halts with it held

`panic::last_words` holds the console registers (`serial::panic_registers`)
for its report, and `serial::panic_flush` for its drain. On the DOUBLE PANIC
path and the first `panic_flush` of an ordinary panic (`kernel/src/main.rs`),
that is before `halt_all_cpus` has stopped the other CPUs. A panic inside the
hold reaches the reentry guard, which writes under it and halts this CPU
alone, so the hold is never let go: every other CPU's next console burst
waits for it with interrupts off (`BackendLock::lock`), and the console takes
no burst again.

**Evidence:** the code.

**Exit:** no CPU halts holding the console registers while another still runs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
status: open
kind: defect
opened: 2026-10-01
---

# A panic-path write between two console bursts can split a character

`serial::uart_write_fifo` cuts a line into `TX_BURST`-byte bursts wherever the
count falls, so a multibyte character, such as the `—` many of the kernel's
records carry, can straddle two of them. The panic path takes the registers
between a wire holder's bursts (`panic_flush`'s drain, `nested_nmi`'s report),
so its bytes can land between that character's lead byte and the rest, and
the console line they cut is not UTF-8.

The harness's reader stops at the first console line that is not UTF-8
(`publish_line` in `tests/common/qemu.rs`), so the fatal report after it never
reaches an assertion, and the boot waits for QEMU to exit.

**Evidence:** the code. The harness's half was recorded with the unlocked
nested-NMI report as the splitter: main's nightly `guest` lane at `06788146b`,
run 36843762360, job 110374194368, where `nested_nmi_is_loud` ended `QEMU died
before NESTED NMI (status 0)` after 63 s; `PANIC_BOUND_MS` is 60 s. Its capture
stops in the line before the `—` of cpu1's `i8042:` record, and holds neither
the rest of the report nor the panic flush's records.

**Exit:** no console line the panic path writes into carries a split
character.
28 changes: 26 additions & 2 deletions kernel-loom/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ pub mod arch {
pub fn poll() {}
}

/// Which CPU a model thread is, which the console lock's fatal word and the
/// roster's echo name.
/// The model says, through [`become_cpu`]; the kernel asks the CPU. Per
/// model thread for `scheduler`'s reason: loom's threads share an OS one.
#[cfg(feature = "loom")]
pub mod cpu {
loom::thread_local! {
static CPU: core::cell::Cell<Option<u32>> = core::cell::Cell::new(None);
}

/// Say which CPU the current model thread is. No kernel counterpart.
pub fn become_cpu(id: u32) {
CPU.with(|cpu| cpu.set(Some(id)));
}

pub fn hardware_id() -> u32 {
CPU.with(|cpu| cpu.get()).expect("a model thread asked which cpu it is before saying")
}
}

/// **A strictly stronger model than the instruction, and the direction is
/// the whole argument.**
///
Expand Down Expand Up @@ -148,7 +168,9 @@ pub mod sync;
#[path = "../../kernel/src/shootdown.rs"]
pub mod shootdown;

/// The CPU roster and the release/answer word, driven by `tests/smp_bringup.rs`.
/// The CPU roster and the release/answer word, driven by `tests/smp_bringup.rs`;
/// under `loom` alone, as is the [`arch::cpu`] shim it names.
#[cfg(feature = "loom")]
#[path = "../../kernel/src/smp_roster.rs"]
pub mod smp_roster;

Expand Down Expand Up @@ -352,6 +374,8 @@ pub mod log_ring;
#[path = "../../kernel/src/drivers/panic_console/published.rs"]
pub mod panic_console_published;

/// The console backend's lock, driven by `tests/serial_lock.rs`.
/// The console backend's lock, driven by `tests/serial_lock.rs`; under `loom`
/// alone, as is the [`arch::cpu`] shim it names.
#[cfg(feature = "loom")]
#[path = "../../kernel/src/drivers/serial_lock.rs"]
pub mod serial_lock;
148 changes: 144 additions & 4 deletions kernel-loom/tests/serial_lock.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
//! Loom: a lost `try_lock` on the console backend leaves its holder holding.
//! Loom: a lost `try_lock` on the console backend leaves its holder holding;
//! a fatal path's `seize` waits for a holder that lets go, gives up on one that
//! never does, and has its own CPU's hold at once; and a burst beneath its own
//! CPU's fatal path is refused rather than spun on.
//!
//! The console drain takes the backend with `try_lock` from every CPU that
//! logs, so a losing attempt is the common case beside another CPU's write.
//! A loss that released the lock let a third writer in under the holder. The
//! negative control is the `serial-try-lock-then-some` feature, which must red
//! this file.
#![cfg(feature = "loom")]

use kernel_loom::serial_lock::BackendLock;
use kernel_loom::arch::cpu::become_cpu;
use kernel_loom::serial_lock::{within, BackendLock, Seized};
use loom::cell::UnsafeCell;
use loom::sync::Arc;
use loom::thread;
Expand Down Expand Up @@ -49,3 +51,141 @@ fn two_writers_never_overlap() {
other.join().unwrap();
});
}

/// The bound is the count asked for: an attempt that never answers is asked
/// exactly `tries` times. In a model only because the pause is loom's.
#[test]
fn within_asks_a_silent_attempt_exactly_its_tries() {
for tries in [0, 1, 100] {
loom::model(move || {
let mut asked = 0;
let answer = within(tries, || {
asked += 1;
None::<()>
});
assert!(answer.is_none(), "an attempt that never answered was answered");
assert_eq!(asked, tries, "a wait bounded at {tries} tries asked {asked} times");
});
}
}

loom::lazy_static! {
/// A lock whose burst can be handed to another thread: [`Held`] borrows it.
static ref LOCK: BackendLock = BackendLock::new();
}

/// The wait inside the bound: a burst held when a seize begins, and let go on
/// another CPU, is taken. Each time the seize runs again before the holder has
/// let go, which loom counts as a preemption of the holder, costs it a try, so
/// `k` of them leave `k + 2` tries enough; unbounded, no count would be.
#[test]
fn a_seize_takes_a_burst_let_go_inside_the_bound() {
for preemptions in 0..=2u8 {
let mut model = loom::model::Builder::new();
model.preemption_bound = Some(usize::from(preemptions));
model.check(move || {
become_cpu(0);
let burst = LOCK.try_lock().expect("an unheld lock refused its first taker");
let holder = thread::spawn(move || drop(burst));
let taken = matches!(LOCK.seize(u64::from(preemptions) + 2), Seized::Taken(_));
// Joined first: a holder never run still owns the burst, whose
// drop outside the model would abort the binary rather than fail.
holder.join().unwrap();
assert!(taken, "a fatal path gave up on a burst that let go inside the bound");
});
}
}

/// Nothing on a fatal path waits for ever, and a holder that never lets go is
/// what it would wait for.
#[test]
fn a_seize_gives_up_on_a_holder_that_never_lets_go() {
loom::model(|| {
become_cpu(0);
let lock = BackendLock::new();
let _burst = lock.try_lock().expect("an unheld lock refused its first taker");
assert!(matches!(lock.seize(3), Seized::Expired), "a seize took a lock its holder kept");
});
}

/// A fatal path entered on top of its own CPU's has the lock at once, and
/// another CPU's still waits the holder out.
#[test]
fn a_seize_reenters_its_own_cpus_hold() {
loom::model(|| {
let lock = Arc::new(BackendLock::new());
become_cpu(1);
let Seized::Taken(held) = lock.seize(1) else { panic!("a free lock refused a seize") };
assert!(matches!(lock.seize(1), Seized::Reentered), "a fatal path waited out its own CPU's hold");
assert!(lock.try_lock().is_none(), "a burst took a fatal path's hold");
let other = {
let lock = lock.clone();
thread::spawn(move || {
become_cpu(2);
matches!(lock.seize(1), Seized::Expired)
})
};
assert!(other.join().unwrap(), "a fatal path took another CPU's hold");
drop(held);
assert!(matches!(lock.seize(1), Seized::Taken(_)), "a released lock stayed held");
});
}

/// A fatal path and a burst on two CPUs: whoever gets in writes alone.
#[test]
fn a_seize_and_a_burst_never_overlap() {
loom::model(|| {
let lock = Arc::new(BackendLock::new());
let line = Arc::new(UnsafeCell::new(0u32));
let burst = {
let (lock, line) = (lock.clone(), line.clone());
thread::spawn(move || {
if let Some(held) = lock.try_lock() {
// SAFETY: the backend is held, so no other writer is in here.
line.with_mut(|n| unsafe { *n += 1 });
drop(held);
}
})
};
become_cpu(0);
if let Seized::Taken(held) = lock.seize(2) {
// SAFETY: as above.
line.with_mut(|n| unsafe { *n += 1 });
drop(held);
}
burst.join().unwrap();
});
}

/// A burst on the CPU whose fatal path holds the registers would wait for a
/// frame beneath it: it is refused, loudly.
#[test]
#[should_panic(expected = "serial: a burst asked for the console registers this cpu's own fatal path holds")]
fn a_burst_beneath_its_own_cpus_fatal_path_is_refused() {
loom::model(|| {
become_cpu(1);
let lock = BackendLock::new();
let _fatal = lock.seize(1);
drop(lock.lock());
});
}

/// A burst on another CPU waits a fatal path's hold out, and has the lock
/// once it is let go.
#[test]
fn a_burst_waits_out_another_cpus_fatal_path() {
loom::model(|| {
let lock = Arc::new(BackendLock::new());
become_cpu(1);
let Seized::Taken(fatal) = lock.seize(1) else { panic!("a free lock refused a seize") };
let burst = {
let lock = lock.clone();
thread::spawn(move || {
become_cpu(2);
drop(lock.lock());
})
};
drop(fatal);
burst.join().unwrap();
});
}
Loading
Loading