Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
244ad0a
Stage 6 step 1: a device's handler posts its watch; irq_ring's UserDe…
Japabu Sep 29, 2026
44fe283
handler-post holds only on a CPU that takes interrupts
Japabu Sep 29, 2026
be715c7
A thread's post fires its polls with the list let go; only a handler …
Japabu Sep 30, 2026
8cd2948
Stage 6 step 1's plan line loses its claim that a post frees nothing
Japabu Sep 30, 2026
bee8c9d
Stage 6 step 5's exit is what the step can reach
Japabu Sep 30, 2026
3007ff2
Merge remote-tracking branch 'origin/main' into wt/toyos-sk6
Japabu Sep 30, 2026
b236cce
Merge origin/main (#625) into wt/toyos-sk6
Japabu Sep 30, 2026
a50015b
Only the watches a handler posts mask interrupts, and they cannot free
Japabu Sep 30, 2026
5ed2f6e
Stage 6 stays open for the panel ruling, and step 2's exit can fail
Japabu Sep 30, 2026
fb0fc7b
The poll race model holds its watch past its assertion
Japabu Sep 30, 2026
86595cc
Merge origin/main (#632, #536) into wt/toyos-sk6
Japabu Sep 30, 2026
309153b
The poll race model drops its world after its assertion, by name
Japabu Sep 30, 2026
9e4d712
Merge remote-tracking branch 'origin/main' into wt/toyos-sk6
Japabu Sep 30, 2026
d6a04fa
Answer the fourth review of #634: no post drops an entry, no handler …
Japabu Sep 30, 2026
84275af
Merge remote-tracking branch 'origin/main' into wt/toyos-sk6
Japabu Sep 30, 2026
f4ed0e7
Answer the fifth review of #634: a released claim's polls are let go …
Japabu Sep 30, 2026
485e66f
Answer the sixth review of #634: one cancel for every watch, a thread's
Japabu Sep 30, 2026
d098f0d
issues: an IrqWatch's freeing cancel compiles in a handler
Japabu Sep 30, 2026
5462f5e
Merge remote-tracking branch 'origin/main' into wt/toyos-sk6
Japabu Sep 30, 2026
0a9eb49
Answer the final review of #634: an audio claim's release answers its…
Japabu Sep 30, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
status: open
kind: tooling
opened: 2026-09-30
---

# The two-watch poll model counts a dropped watch's answer as its completion

`toyos-sched/loom/tests/loom_watch.rs`'s
`a_poll_on_two_watches_racing_both_posts_completes_exactly_once` moves both
worlds into their producer threads, so both watches drop before its assertion,
and a watch's drop fires every live entry as `Fire::Gone`, which the model's
`Entry` counts as a post. Its "never by neither" half cannot fail: a poll no
post and no recheck completed is completed by the drop.

**Evidence:** with each producer posting before it makes its condition true (a
lost completion by construction), `cargo test -p toyos-sched-loom --test
loom_watch a_poll_on_two_watches` is EXIT=0, 1 passed. `poll_racing`, the
single-watch model beside it, had the same shape and holds its world past the
assertion since #634.

**Exit:** the model keeps both worlds alive past its assertions, and the
mutation above reds it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
status: assigned
kind: defect
opened: 2026-09-30
---

# A process lengthens an interrupts-off walk by the threads it parks on one ring

Held by the small-kernel track's stage 6 step 2
(`issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md`),
whose instrument is the only thing that can read it.

Every poll ring's own watch and its completions sit behind an `IrqLock`
(`kernel/src/inbox/mod.rs`), because a device handler's post
reaches them through the polls it fires. So any process, not only a device's
holder, decides how long a CPU runs with interrupts masked:

- **N threads parked in `submit` on one ring**
are N registrations on its watch. Every completion into that ring posts the
watch in place, which notifies all N under the list lock
(`toyos-sched/src/watch.rs`), each a word exchange and, for a parked
thread, a mailbox push and perhaps an IPI (`toyos-sched/src/park.rs`).
Each woken thread's unregister is a `position` and a
`remove` over the N, and a registration
that finds the list full copies it, all with interrupts masked.
- **A claim's holder polling its claim from R rings, P polls each** (up to
`MAX_PENDING_WATCHES`, 1024) makes its device's
handler fire R × P entries under the claim's list lock, each taking that
ring's completions lock and posting that ring's watch, whose own N threads
it notifies. Entries a post in place fired stay in the list until
registrations sweep them four at a time.

Nothing caps N: a thread costs its process a 128 KiB kernel stack
(`kernel/src/process.rs`) and no count. Before #634 every one of these
walks ran with interrupts open, under preemption off. By reading, not
measured: no instrument in the tree reads an interrupts-off window.

**Exit**: step 2's interrupts-off window, read on the T14 while one process
parks 256 threads in `submit` on one ring and a sibling thread completes into
it, is no longer at step 1's head than at stage 6's first commit under the
same load.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: assigned
kind: defect
opened: 2026-09-30
---

# An IrqWatch's freeing cancel compiles in a handler

Held by the small-kernel track's stage 6 step 5
(`issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md`),
whose exit reads it.

An interrupt handler may not free: it can interrupt the allocator's holder.
`IrqWatch` has no `post`, so a thread's post written in a handler is refused
at build. But `cancel_polls` is every watch's, and it frees every entry it
takes out. Its callers are a claim's release and the close of a claim or an
audio device, both threads', and no type keeps it out of a handler.

**Evidence**: `WATCHES[slot].cancel_polls();` written after
`pcidev::note_fault`'s post builds: the x86-64 kernel clippy shape exits 0.
The kernel holds no proof of thread context a handler cannot mint. `Parkable`
proves a context may park, and with `IrqWatch`'s cancel taking one,
`Parkable::at_entry()` written at the same line in `note_fault` builds too
(clippy 0). Its only check is at run time, an assertion on the preempt
depth: by reading, a handler that interrupted Ring 3 passes it.

**Exit**: a proof of thread context no interrupt handler can construct, taken
by `IrqWatch`'s `cancel_polls`, so the `note_fault` line above is refused at
build.
2 changes: 1 addition & 1 deletion issues/kernel/every-wait-in-this-kernel-is-a-spin.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ both before any lock conversion; the order is forced, not preferred.

- A watch is a node the waiter lends to the object, and the subject is a
borrowed reference, never an id. **Rejected:** a global registry, a slot
arena, two park channels, posting from interrupt context, multishot polls,
arena, two park channels, multishot polls,
userspace-only blocking wrappers, a sleep lock that spins where it cannot
park, poisoning, and shootdown-as-completion. A freed object cannot be named.
- The park token proves the *context* may park and never encodes which locks are
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
status: assigned
kind: defect
opened: 2026-09-30
---

# Nothing fails when a device's release or close stops answering its polls

Held by the small-kernel track's stage 6 step 5
(`issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md`),
whose exit reads it.

A device's watch is an `IrqWatch`, and three thread sites answer the polls on
it as gone: `pcidev::tear_down` when a claimed function is released
(`kernel/src/pcidev/mod.rs:1489`), `Claim::drop` when an audio claim goes
(`kernel/src/device.rs:86`), and the close of a claim or an audio device
through `WatchRef::cancel_polls`'s `Irq` arm (`kernel/src/object/ops.rs:263`).
A poll none of them answers waits on interrupts that are the next holder's or
nobody's. No host test compiles `pcidev`, `device` or `ops`, and no guest test
ends a polled device, so each call can go and nothing reds.

**Evidence**: each deletion builds. The x86-64 kernel clippy shape exits 0
with the release's `cancel_polls()` deleted, 0 with the `Irq` arm made `{}`,
and 0 with the audio claim's cancel made `{}`.

**Exit**: `WatchRef`'s cancel is one dispatch over every variant, as main's
`Deref` was, so the `Irq` arm above cannot be written apart from the others;
and a guest test, in which a claim's holder exits with a poll of its claim on a
ring another process holds, reads that poll answered `-NotFound` for a claimed
function and for an audio device, and reds with the release's cancel deleted
and with the audio claim's.
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,53 @@ times:
written once as straight-line code. **Exit**: no interrupts-off window
longer than a register access, and keyboard input keeps flowing while a
stick misbehaves.
6. **The scheduler knows nothing about devices.** Interrupt handlers only post
to their device's `Watch`, and the device's thread does the work. The
per-CPU IRQ relay, the driver list in the scheduler pass and the idle
special cases are deleted.
6. **The scheduler knows nothing about devices.** A handler posts its
device's `Watch` and ends its interrupt; the thread waiting on that watch
does the work, and no step creates a kernel thread. `irq_ring`, the driver
list in `drain_irqs` and the idle loop's device checks are gone by step 5.
Each step measures the kernel's lines, and from step 2 the longest
interrupts-off and preemption-off windows, against stage 6's first commit.
Steps 3 and 4 do not land alone: they land with #592's i8042 stage and
with usbd. Stage 6 stays open past step 5 until the owner rules on the
panel the dump paints.
1. **Interrupts post.** A post is legal in a handler: the watches a handler
posts, and the completions of a ring they complete into, sit behind
interrupts-off locks nothing allocates or frees under, and every other
watch's lock leaves interrupts open. A claimed function's vector, the
IOMMU's refusal and both audio backends post from the handler, and
`irq_ring`'s `UserDev` and `Audio` and their arms in `drain_irqs` go.
The thread is the holder's: netd's, blockd's, soundd's mix thread, and
the `isa` claim's when #592 lands. **Exit**: `handler_post_without_a_pass`,
a vector taken on a CPU holding preemption off, inside a post of its own
watch, inside a completion into a ring polling it, or inside that ring's
own watch, posting once that section lets go and before any pass, red on
the base; the watch's loom models over the new post.
2. **The windows, measured**: the longest interrupts-off and preemption-off
windows per CPU, reported beside the IRQ census and fed by each
architecture's masking primitives and entries, the number the ARM
track's stage 4 owes as well. Applied to stage 6's first commit for
the baseline. **Exit**: both windows read on the T14, which is x86
metal, at stage 6's start and at step 1's head, and neither is longer
at step 1's head than at the start, under the load
`issues/kernel/a-process-lengthens-an-interrupts-off-walk-by-the-threads-it-parks-on-one-ring.md`
names as well.
3. **The i8042's thread is ps2server's** (#592's i8042 stage): `irq_ring`'s
`I8042`, `keyboard_controller::service` and the idle loop's
`verdict_due` go with the kernel's driver. **Exit**: that stage's.
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` 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 still paints its
report on the panel and holds it there, a device the pass reaches;
whether that stays is the owner's ruling. **Exit**: `drain_irqs` and the
idle loop's device checks are gone, both windows are measured against
stage 6's start, and the exits of
`issues/kernel/an-irq-watchs-freeing-cancel-compiles-in-a-handler.md`
and
`issues/kernel/nothing-fails-when-a-devices-release-or-close-stops-answering-its-polls.md`
are met.

## Standing

Expand Down
10 changes: 10 additions & 0 deletions issues/kernel/toyos-runs-on-arm64.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,16 @@ Each stage names its exit; "measured" means a number from a run.
before its body) get a test here that reds with `put` written back as one
`self.buf.write(off, trb)`; x86's TSO hides all three from every guest
test until then.
**The claim's handler is the first arm of `irq()`
(`kernel/src/arch/aarch64/trap.rs:135`) that posts a watch or lets go of a
`Lock`**, and either runs `preempt::enable`, whose pass at depth zero
(`kernel/src/preempt.rs:67`) reads nothing of `DAIF`; `do_preempt`'s
`assert_baseline(BASELINE_IRQ_EXIT)` (`kernel/src/scheduler.rs:358`) passes
at depth zero, so that pass would run inside the handler, before
`irqchip::end`. Owed before that arm lands: `irq()` holds the preempt count
across every device arm, as x86-64's `device_irq_entry` does, or
`preempt::enable` refuses a pass with interrupts masked, which `IrqOff`'s
SAFETY (`kernel/src/sched/driver.rs:49`) already assumes.

7. **Userland boots.** `init`, `logd`, the compositor, netd, soundd and sshd,
built for `aarch64-unknown-toyos`. C programs stay x86-only until this
Expand Down
5 changes: 2 additions & 3 deletions kernel-loom/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,8 @@ 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.
sleeplock-acquire-off = []
# The negative control for a claimed PCI function's interrupt record. Its two
# read-modify-writes — the reader's `swap` of the count and the scheduler pass's
# `swap` of the wake flag — become a load and a store, which is the whole of
# The negative control for a claimed PCI function's interrupt record. The
# reader's `swap` of the count becomes a load and a store, which is the whole of
# what this record's design is, and `device_irq.rs` must red:
#
# cargo test --manifest-path kernel-loom/Cargo.toml --features device-irq-lossy \
Expand Down
81 changes: 11 additions & 70 deletions kernel-loom/tests/device_irq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,25 @@
//!
//! The kernel programs one MSI-X vector per claimed function and accumulates
//! what arrives into a record its holder reads through a syscall. So there are
//! two parties on two CPUs and two words between them: an ISR that bumps a
//! count and arms a wake, a reader that takes the count, and a scheduler pass
//! that takes the wake.
//! two parties on two CPUs and one word between them: an ISR that bumps a
//! count, and a reader that takes it.
//!
//! **The invariant is that every message is counted exactly once and owes
//! exactly one wake.** Nothing here orders anything else — each word is the
//! whole of what it says, so the orderings are `Relaxed` and every property is
//! an interleaving. What makes them hold is that the taking side of both words
//! is a read-modify-write: a reader that loaded a count and then cleared it
//! drops every message the ISR recorded in between, and two scheduler passes
//! that both loaded a wake flag both wake one message's watchers.
//! **The invariant is that every message is counted exactly once.** Nothing
//! here orders anything else — the word is the whole of what it says, so the
//! orderings are `Relaxed` and the property is an interleaving. What makes it
//! hold is that the taking side is a read-modify-write: a reader that loaded a
//! count and then cleared it drops every message the ISR recorded in between.
//!
//! That pair is the record's whole design, so the negative control is the pair
//! turned off — a cargo feature rather than a comment:
//! That is the record's whole design, so the negative control is it turned off
//! — a cargo feature rather than a comment:
//!
//! ```text
//! cargo test --manifest-path kernel-loom/Cargo.toml --features device-irq-lossy \
//! --test device_irq
//! ```
//!
//! makes both `swap`s a load and a store and the ISR's `fetch_add` a load, an
//! add and a store, and this file must red — at
//! [`every_message_is_counted_once`] and at [`one_message_is_one_wake`], which
//! are the two defects stated exactly.
//! makes the `swap` a load and a store and the ISR's `fetch_add` a load, an
//! add and a store, and [`every_message_is_counted_once`] must red.

use kernel_loom::device_irq::Interrupt;
use loom::sync::Arc;
Expand Down Expand Up @@ -62,32 +57,6 @@ fn every_message_is_counted_once() {
});
}

/// A wake is owed exactly once per message, and the pass that owes it is the
/// one that takes it.
///
/// The wake and the ISR are the *same* CPU in the kernel — the scheduler pass
/// that drains runs after the handler that armed it, with interrupts off in
/// between — so this models the weaker thing that must also hold: two passes
/// racing each other never both wake, and never both decline.
#[test]
fn one_message_is_one_wake() {
loom::model(|| {
let irq = Arc::new(Interrupt::new());
irq.took();

let other = {
let irq = irq.clone();
loom::thread::spawn(move || irq.take_pending())
};

let mine = irq.take_pending();
let theirs = other.join().unwrap();

assert!(!(mine && theirs), "two passes both woke one message's watchers");
assert!(mine || theirs, "neither pass woke a message that had already arrived");
});
}

/// A reader that finds nothing answers nothing, and leaves nothing behind.
///
/// Single-threaded and deliberately so: this is the answer on the path a
Expand All @@ -106,31 +75,3 @@ fn an_idle_record_answers_nothing() {
assert!(!irq.armed(), "a drained record still reads ready");
});
}

/// A fault owes its holder a wake, and the pass that takes it reads the fault.
///
/// The fault handler and the scheduler pass that turns the wake into a wake-up
/// may be different CPUs, and what the woken holder reads next is the refusal:
/// a pass that took the wake and still read the claim unfaulted would wake a
/// holder into reading "no interrupt" and parking again, for a function that
/// can no longer send one. A `Relaxed` wake fails the first assertion, and a
/// fault that posts no wake the last.
#[test]
fn a_faults_wake_carries_the_fault() {
loom::model(|| {
let irq = Arc::new(Interrupt::new());

let handler = {
let irq = irq.clone();
loom::thread::spawn(move || irq.fault())
};

let taken_before = irq.take_pending();
if taken_before {
assert!(irq.faulted(), "a pass took a fault's wake and read the claim unfaulted");
}
handler.join().unwrap();
assert!(irq.faulted());
assert!(taken_before || irq.take_pending(), "a fault owed its holder a wake and posted none");
});
}
8 changes: 4 additions & 4 deletions kernel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ log-commit-release-off = []
# `src/log/registry.rs`'s pointer store and load go `Relaxed`, and
# `log_publish` reds.
shard-publish-relaxed = []
# `src/pcidev/record.rs`'s two read-modify-writes become a load and a store, so
# a message the ISR records between a reader's two halves is lost and a wake can
# be owed twice, and `device_irq` reds. That pair *is* the record's design, so
# turning it off reverts the whole of what the model claims.
# `src/pcidev/record.rs`'s read-modify-writes become a load and a store, so a
# message the ISR records between a reader's two halves is lost, and
# `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.
Expand Down
6 changes: 6 additions & 0 deletions kernel/src/actuator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ actuators! {
/// parking, so a post lands in the window its commit must refuse the park over.
watch_window = "watch-window";

/// Raise an unheld claim slot's vector inside a post of its own watch,
/// inside a completion into a ring polling it, and inside that ring's own
/// watch, while the CPU holds preemption off, and count whether the
/// handler posted it there.
handler_post = "handler-post";

/// Starve the four xHCI bring-up register waits in `init_one`.
xhci_deaf_controller = "xhci-deaf-controller";

Expand Down
2 changes: 1 addition & 1 deletion kernel/src/arch/x86_64/idt/hda.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::device_irq::device_irq_entry;

// Lock-free, heap-free: may interrupt a CPU holding the controller lock (preemption disabled, not interrupts).
// Heap-free, and takes only interrupts-off locks: may interrupt a CPU holding the controller lock (preemption disabled, not interrupts).
extern "sysv64" fn hda_handler() {
crate::arch::percpu::irq_took!(Hda);
crate::drivers::hda::isr_complete();
Expand Down
8 changes: 2 additions & 6 deletions kernel/src/arch/x86_64/idt/user_dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,14 @@
//! wake every user driver in the machine on any of their interrupts, which is
//! one process learning when another's device is busy.
//!
//! Lock-free and heap-free like every other device entry here: the record is
//! atomics and the wake happens on the next scheduler pass.
//! Heap-free like every other device entry here: the record is atomics, and
//! the claim's watch is posted from the handler.

use super::device_irq::device_irq_entry;
use crate::irq_ring::IrqSource;

fn took(slot: usize) {
crate::arch::percpu::irq_took!(UserDev);
crate::pcidev::isr(slot);
crate::irq_ring::isr_publish(IrqSource::UserDev, crate::clock::nanos_since_boot());
// Force resched now, so `drain_irqs` turns the record into a wake before
// the next quantum tick rather than after it.
crate::preempt::set_need_resched();
crate::arch::apic::eoi();
}
Expand Down
2 changes: 1 addition & 1 deletion kernel/src/arch/x86_64/idt/virtio_sound.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::device_irq::device_irq_entry;

// Lock-free and heap-free: may interrupt a CPU holding the controller lock, which disables preemption but not interrupts.
// Heap-free, and takes only interrupts-off locks: may interrupt a CPU holding the controller lock, which disables preemption but not interrupts.
extern "sysv64" fn virtio_sound_handler() {
crate::arch::percpu::irq_took!(Sound);
crate::drivers::virtio_sound::isr_complete();
Expand Down
Loading
Loading