diff --git a/issues/build/the-two-watch-poll-model-counts-a-dropped-watchs-answer-as-its-completion.md b/issues/build/the-two-watch-poll-model-counts-a-dropped-watchs-answer-as-its-completion.md
new file mode 100644
index 00000000000..899c9c18517
--- /dev/null
+++ b/issues/build/the-two-watch-poll-model-counts-a-dropped-watchs-answer-as-its-completion.md
@@ -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.
diff --git a/issues/kernel/a-process-lengthens-an-interrupts-off-walk-by-the-threads-it-parks-on-one-ring.md b/issues/kernel/a-process-lengthens-an-interrupts-off-walk-by-the-threads-it-parks-on-one-ring.md
new file mode 100644
index 00000000000..b38d2a9496f
--- /dev/null
+++ b/issues/kernel/a-process-lengthens-an-interrupts-off-walk-by-the-threads-it-parks-on-one-ring.md
@@ -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.
diff --git a/issues/kernel/an-irq-watchs-freeing-cancel-compiles-in-a-handler.md b/issues/kernel/an-irq-watchs-freeing-cancel-compiles-in-a-handler.md
new file mode 100644
index 00000000000..9b29532b441
--- /dev/null
+++ b/issues/kernel/an-irq-watchs-freeing-cancel-compiles-in-a-handler.md
@@ -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.
diff --git a/issues/kernel/every-wait-in-this-kernel-is-a-spin.md b/issues/kernel/every-wait-in-this-kernel-is-a-spin.md
index e86fa18ee1b..b8c7cd78b4f 100644
--- a/issues/kernel/every-wait-in-this-kernel-is-a-spin.md
+++ b/issues/kernel/every-wait-in-this-kernel-is-a-spin.md
@@ -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
diff --git a/issues/kernel/nothing-fails-when-a-devices-release-or-close-stops-answering-its-polls.md b/issues/kernel/nothing-fails-when-a-devices-release-or-close-stops-answering-its-polls.md
new file mode 100644
index 00000000000..47cccb8b434
--- /dev/null
+++ b/issues/kernel/nothing-fails-when-a-devices-release-or-close-stops-answering-its-polls.md
@@ -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.
diff --git a/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md
index 696e5048872..e7065240b7c 100644
--- a/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md
+++ b/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md
@@ -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
diff --git a/issues/kernel/toyos-runs-on-arm64.md b/issues/kernel/toyos-runs-on-arm64.md
index b93f81a9881..123db6c3868 100644
--- a/issues/kernel/toyos-runs-on-arm64.md
+++ b/issues/kernel/toyos-runs-on-arm64.md
@@ -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
diff --git a/kernel-loom/Cargo.toml b/kernel-loom/Cargo.toml
index 86ccc7df2c7..53a3aef831e 100644
--- a/kernel-loom/Cargo.toml
+++ b/kernel-loom/Cargo.toml
@@ -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 \
diff --git a/kernel-loom/tests/device_irq.rs b/kernel-loom/tests/device_irq.rs
index 450046d45eb..d5370bf87fb 100644
--- a/kernel-loom/tests/device_irq.rs
+++ b/kernel-loom/tests/device_irq.rs
@@ -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;
@@ -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
@@ -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");
- });
-}
diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml
index 1d42053dd63..5beca3f086b 100644
--- a/kernel/Cargo.toml
+++ b/kernel/Cargo.toml
@@ -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.
diff --git a/kernel/src/actuator.rs b/kernel/src/actuator.rs
index 0119f80c880..89cc9f2dc42 100644
--- a/kernel/src/actuator.rs
+++ b/kernel/src/actuator.rs
@@ -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";
diff --git a/kernel/src/arch/x86_64/idt/hda.rs b/kernel/src/arch/x86_64/idt/hda.rs
index 579bebedfc6..d75e99ef709 100644
--- a/kernel/src/arch/x86_64/idt/hda.rs
+++ b/kernel/src/arch/x86_64/idt/hda.rs
@@ -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();
diff --git a/kernel/src/arch/x86_64/idt/user_dev.rs b/kernel/src/arch/x86_64/idt/user_dev.rs
index 18142f94df5..9ad2dccbc32 100644
--- a/kernel/src/arch/x86_64/idt/user_dev.rs
+++ b/kernel/src/arch/x86_64/idt/user_dev.rs
@@ -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();
}
diff --git a/kernel/src/arch/x86_64/idt/virtio_sound.rs b/kernel/src/arch/x86_64/idt/virtio_sound.rs
index 98060824d62..6e3744d624a 100644
--- a/kernel/src/arch/x86_64/idt/virtio_sound.rs
+++ b/kernel/src/arch/x86_64/idt/virtio_sound.rs
@@ -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();
diff --git a/kernel/src/arch/x86_64/mod.rs b/kernel/src/arch/x86_64/mod.rs
index 41ec8a0f805..1e565f53f79 100644
--- a/kernel/src/arch/x86_64/mod.rs
+++ b/kernel/src/arch/x86_64/mod.rs
@@ -52,10 +52,8 @@ pub const ELF_MACHINE: toyos_elf::Machine = toyos_elf::Machine::X86_64;
/// Interrupts masked on this CPU for as long as the guard lives, and then put
/// back as they were — restored, not enabled — so a guard nests inside a region
-/// that is already masked. The one way this kernel masks and restores: the
-/// scheduler's pass, a log record's reservation and publication, and the
-/// console backend each hold one. `TF` is always clear in Ring 0, so the guard
-/// leaves it alone.
+/// that is already masked. The one way this kernel masks and restores. `TF` is
+/// always clear in Ring 0, so the guard leaves it alone.
///
/// Both edges are compiler barriers (no `nomem`): a memory access written
/// inside the region is emitted inside it.
diff --git a/kernel/src/arch/x86_64/vtd/fault.rs b/kernel/src/arch/x86_64/vtd/fault.rs
index c0188b87c1e..3de98c0f382 100644
--- a/kernel/src/arch/x86_64/vtd/fault.rs
+++ b/kernel/src/arch/x86_64/vtd/fault.rs
@@ -1,6 +1,6 @@
//! Vt-d fault interrupt handling: MSI-delivered, never polled.
//!
-//! The handler is bounded, allocates nothing and takes no lock; unit and
+//! The handler is bounded, allocates nothing; unit and
//! function state lives in fixed arrays of atomics, written once before the
//! mask comes off. Whatever the stream, the same things happen first: Bus
//! Master Enable cleared on the function that faulted, the first record latched
@@ -36,8 +36,7 @@ const FSTS_OVERFLOW: u32 = 1 << 0;
// One fault recording register's F bit, in the 32-bit word that carries it.
const RECORD_FAULT: u32 = 1 << 31;
-// Atomics only: the handler takes no lock; each field is written once before
-// the unit's mask comes off.
+// Atomics only: each field is written once before the unit's mask comes off.
struct FaultUnit {
// Physical base of the register window; 0 means this slot is unused.
// vtd::window refuses a base of 0, so no armed unit can collide with the sentinel.
diff --git a/kernel/src/device.rs b/kernel/src/device.rs
index 17c7f60d5a0..66935836c20 100644
--- a/kernel/src/device.rs
+++ b/kernel/src/device.rs
@@ -79,7 +79,20 @@ impl Claim {
impl Drop for Claim {
fn drop(&mut self) {
match self.what {
- Claimed::Class(class) => *taken(class).lock() = false,
+ Claimed::Class(class) => {
+ // Before the flag goes: a poll the next holder registers is not this claim's to answer.
+ match class {
+ DeviceType::HdaAudio | DeviceType::VirtioSound => {
+ crate::drivers::AUDIO_WATCH.cancel_polls()
+ }
+ DeviceType::Keyboard
+ | DeviceType::Mouse
+ | DeviceType::Framebuffer
+ | DeviceType::PciFunction
+ | DeviceType::Partition => {}
+ }
+ *taken(class).lock() = false;
+ }
// Bus mastering off, then the domain, then the pages: `release`
// owns that order, and this is where a dying process reaches it.
Claimed::PciFunction(slot) => crate::pcidev::release(slot),
diff --git a/kernel/src/drivers/hda.rs b/kernel/src/drivers/hda.rs
index 135c620c34e..ca6c39b12be 100644
--- a/kernel/src/drivers/hda.rs
+++ b/kernel/src/drivers/hda.rs
@@ -166,7 +166,7 @@ pub fn isr_complete() {
}
ISR.timestamp.store(timestamp, Ordering::Relaxed);
ISR.mask.fetch_or(mask, Ordering::Release);
- crate::irq_ring::isr_publish(crate::irq_ring::IrqSource::Audio, timestamp);
+ super::AUDIO_WATCH.post_in_place();
crate::preempt::set_need_resched();
}
diff --git a/kernel/src/drivers/mod.rs b/kernel/src/drivers/mod.rs
index d71937ef0b7..a4f8cf9f890 100644
--- a/kernel/src/drivers/mod.rs
+++ b/kernel/src/drivers/mod.rs
@@ -23,4 +23,4 @@ pub use crate::mm::DmaPool;
/// What an audio read and an audio poll wait on: one for both backends, since
/// at most one binds and the claim's reader cannot know which.
-pub static AUDIO_WATCH: crate::watch::Watch = crate::watch::Watch::new();
+pub static AUDIO_WATCH: crate::watch::IrqWatch = crate::watch::IrqWatch::new();
diff --git a/kernel/src/drivers/virtio_sound.rs b/kernel/src/drivers/virtio_sound.rs
index 00e91ecf2ca..416d9001b5f 100644
--- a/kernel/src/drivers/virtio_sound.rs
+++ b/kernel/src/drivers/virtio_sound.rs
@@ -108,8 +108,7 @@ pub fn isr_complete() {
return;
}
isr_push_completion(mask, timestamp);
- crate::irq_ring::isr_publish(crate::irq_ring::IrqSource::Audio, timestamp);
- // Force a scheduler entry on IRQ return so the record becomes wakes now, not at next tick.
+ super::AUDIO_WATCH.post_in_place();
crate::preempt::set_need_resched();
}
diff --git a/kernel/src/inbox/mod.rs b/kernel/src/inbox/mod.rs
index d7c69cc7906..8e6ed014fd1 100644
--- a/kernel/src/inbox/mod.rs
+++ b/kernel/src/inbox/mod.rs
@@ -19,14 +19,18 @@
//! process's ring or say something no object said. A post writes at most one
//! entry, and a ring holds at most [`MAX_PENDING_WATCHES`] polls.
//!
-//! **Locks.** A ring's own lock takes nothing under it and is never taken under
-//! a watch's: a post fires its polls with its list let go. A ring's own watch
+//! **Locks.** What a completion writes, and the page it is written into, sit
+//! behind an [`IrqLock`] of their own, because a device's interrupt handler
+//! posts in place, firing its polls under its list lock; nothing is taken
+//! under it. The rest of a ring, its submissions and its polls, is its
+//! `Lock`'s, which no post reaches. A ring's own watch is an [`IrqWatch`] and
//! holds only threads, because no handle names a ring as a thing to watch.
use alloc::sync::Arc;
use alloc::vec::Vec;
use core::sync::atomic::Ordering;
+use toyos_sched::sync::CellLock;
use toyos_sched::task::WaitClass;
use toyos_sched::watch::{Fire, Ring};
@@ -36,7 +40,7 @@ use crate::process::{self, Pid};
use crate::scheduler;
use crate::sync::Lock;
use crate::time::{Deadline, Duration};
-use crate::watch::Watch;
+use crate::watch::{IrqLock, IrqWatch};
use crate::DirectMap;
use toyos_abi::inbox::{
@@ -62,7 +66,12 @@ impl InboxRef {
impl Drop for InboxRef {
fn drop(&mut self) {
- // Taken out under the lock and let go of outside it: the unmap flushes.
+ // The page is the completions', so it goes only once no post can reach
+ // them. Both halves are taken out under their locks and let go of
+ // outside them: the unmap flushes.
+ let Some(completions) = self.0.completions.with(Option::take) else {
+ unreachable!("an inbox is torn down by its one reference, once");
+ };
let Some(mut state) = self.0.state.lock().take() else {
unreachable!("an inbox is torn down by its one reference, once");
};
@@ -70,7 +79,7 @@ impl Drop for InboxRef {
poll.withdraw();
}
// `Unmapped`'s drop flushes; the `Arc` drop after it frees the pages.
- drop(state.shm.unmap_from(state.owner_pid));
+ drop(completions.shm.unmap_from(state.owner_pid));
}
}
@@ -193,68 +202,80 @@ const MAX_PENDING_WATCHES: usize = 1024;
pub struct Inbox {
/// `None` once the ring's one reference let go of it.
state: Lock