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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ an explicit force issues it, and never a timer's.
and the stage reverted whole, beside `signal_alarm`; the routing laws are
host tests (`signal_dispositions.rs`), and so is a wait deciding from one
read of each child's end (`spawn_requests.rs`). libc's own thread also watches its
control pipe, so it watches at most 254 children: the 255th is refused
`EAGAIN`, a `poll` takes 255 descriptors beside its wake pipe, and `SIG_IGN`
control event, so it watches at most 254 children: the 255th is refused
`EAGAIN`, a `poll` takes 255 descriptors beside its wake event, and `SIG_IGN`
is shown over 255 children.
5. **A login is a session under its owner**. **In review** (#1035, the P1b pull
request). Per login the supervisor starts one session owner,
Expand Down
11 changes: 6 additions & 5 deletions issues/a-client-cannot-tell-soundserver-it-paused.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ opened: 2026-08-01

# A client cannot tell soundd it paused, so soundd cannot tell paused from wedged

soundd infers a dead client from a pipe error: `NotFound` is a gone process,
`WouldBlock` is "merely behind". That detects exactly one failure. A client that
stops making progress while holding its fd — a wedged thread, a deadlock, a
callback that never returns — produces `WouldBlock` forever and is
soundd infers a dead client from its signal event alone: a ring answering
`Hungup` is a gone process, and any other ring coalesces into a signal the
client has not taken yet. That detects exactly one failure. A client that
stops making progress while holding its handle — a wedged thread, a deadlock,
a callback that never returns — takes no signal forever and is
indistinguishable from one that deliberately paused. The ambiguity is not a
soundd bug: pause is defined as the client's stream thread simply not reading,
with no coordination, and the cpal backend confirms it — `pause()` stores a
Expand All @@ -18,7 +19,7 @@ state word and parks on a futex, and soundd is never told.
The cost is that `is_streaming` latches on the first delivered period and never
clears, so one paused client holds soundd out of idle for the life of the
process: a wake per period forever, the DMA engine running, the codec voice
open, and the client's shm, signal pipe and slot ring pinned.
open, and the client's shm, signal event and slot ring pinned.

**Design, as far as it is settled.** Liveness is declared intent × observed
progress. Progress already exists as `write_idx`; intent does not. One
Expand Down
4 changes: 2 additions & 2 deletions issues/anonymous-memory-swaps-to-an-encrypted-partition.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ process is dead, counted as that job's `W`, and released only to its next start.
- the block records (24 B per block);
- reverse records (8 B per frame);
- the resident chain;
- the session's doorbell pipe;
- the session's two doorbell events;
- `M`.

**Wiring.** Each job has a wire allowance (Sizes).
Expand Down Expand Up @@ -301,7 +301,7 @@ Each check goes red under the mutation named after "red".
| Stage | After | Builds | Exit |
|---|---|---|---|
| S0-a (`issues/process-memory-in-4-kib-pages-still-copies-each-programs-text.md`'s owner) | — | Every user copy faults or swaps in its chunk before any kernel lock, and pins after a re-check, with no pin across a wait. A lost anonymous page ends the caller, and a lost file-mapping page answers `EIO`. The futex re-translation never waits | Closes `issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md` and `issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md`. **H:** a chunk model in which an anonymous loss ends the caller with no partial result and a file-mapping loss answers `EIO`, red if either takes the other's outcome. **Q:** the pipe and futex sites, red when a copy runs under the pipe lock |
| S0-b (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`'s owner) | — | The block-session client and driver side allocate nothing per I/O: tables sized by `DEPTH`, reads opened straight from the arena. A reopen refused `Held` by a closing session waits for its retirement. A doorbell pipe keeps its first frame if pipes take frames as they fill | **H:** `DEPTH` traffic with an allocator that fails after warm-up, red on any per-I/O allocation; a reopen during a dead session's close answered only after its last completion, red if the hold is released early |
| S0-b (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`'s owner) | — | The block-session client and driver side allocate nothing per I/O: tables sized by `DEPTH`, reads opened straight from the arena. A reopen refused `Held` by a closing session waits for its retirement | **H:** `DEPTH` traffic with an allocator that fails after warm-up, red on any per-I/O allocation; a reopen during a dead session's close answered only after its last completion, red if the hold is released early |
| P1 (`issues/process-memory-in-4-kib-pages-still-copies-each-programs-text.md`'s owner) | prerequisites | Both rules and the frame rule in `kernel/pure/frames.rs`; residency by charged job; `CHARGE`; `vm_wire`, the per-job allowance and additive charges; `resident` and `restart-hold`; the main stack sized by `PT_GNU_STACK` (8 MiB otherwise); resident by kind; unmovable frames in their own superframes; the ledger figures; `S = 0` | **H:** both rules at every `W` and `A` increase, red when a table build skips the backing rule; a resident fault never waits, red when a swappable fault may take a held frame; `SHM_FOR_JOB` to a resident job gives `W` and to an ordinary one `A`; wiring refused past the allowance; a helper shares its job's allowance; an additive wire, red if the charged job pays; two wirers where the first exits, red if any release can be refused or leaves a rule exceeded; superframe invariants under wire and lend; a hold kept whole. **Q:** a `CHARGE`-only handle creates an object `SHM_FOR_JOB` and is refused as a spawn's job and at `SYS_JOB_FLOOR`, red if either refusal accepts it; `mmap_stress`; each resident row's committed and populated totals reported |
| P2 (`issues/process-memory-in-4-kib-pages-still-copies-each-programs-text.md`'s owner) | P1 | Reverse records and the RAM-object reverse index; `mem_scan`; `mem_reclaim` for clean pages and never-writable copied pages; `mem_read`/`mem_write` and `vm_wire_ticket` on the ticket table; watermarks; the kept fallback; superframe evacuation; the `reclaimer` role; service v0 (plain CLOCK) | **H:** revalidation, floor protection, revoke order, an evacuation case; `Pending` without waiting, every non-resident page requested at once, red if a call waits or requests fewer; an unmap cancels a parked ticket and removes the wirer's charge without waiting, red if the unmap waits or the charge stays; `Lost` removes the wirer's charge, red if it stays; a blocking wire of another job refused, red if accepted; a dropped copied page refilled byte-exact, red if a once-`ReadWrite` or resident range is dropped; a version not purged under a copy range, red if the range is not counted; the fallback with a stuck service; a remap between scan and reclaim. **Q:** a page-cache-heavy run with the controls usable |
| C-wire (`issues/toyos-has-a-desktop.md`'s owner) | P1, P2 | Non-blocking wires of on-screen buffers from the loop; shown only once wired; the refusal points; window buffers charged to clients through `CHARGE`; the loop's file I/O (the launcher's app list, the kept scale) moved off it | **H:** red if the loop composes an unwired buffer; a resize keeps the old buffer until wired, red if it swaps early; one parked wire delays no other window; after `Lost` the compositor keeps running and drops that window, red if it ends; a close during a parked wire unmaps without waiting; the kept handle `CHARGE` alone, red if it carries `TRANSFER` or `DUP`. **Q:** with one buffer parked unresident, and with a parked file call, the other windows draw and the stop control works, red when the file call is made on the loop |
Expand Down
9 changes: 7 additions & 2 deletions issues/closing-a-pipe-end-ends-every-holders-polls-on-it.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ watch it still needs as over.
Found by reading, in review of pull request #861, which put the pipe end's
leaving watch in that same loop. No test or machine has shown it.

An event's wait side has the same defect since #1108: `close_ends_polls` is
`true` for `EventWait`, and its one watch is every holder's, so closing a
duplicate of a wait side answers another holder's poll on it as gone. A
listener's descriptor is such a duplicate (found in review of #1108).

**Exit**: a close ends only the polls made through the handle it closes, and a
guest test that holds a duplicate of a pipe end, watches it, and closes the
other handle reads no answer on it.
guest test that holds a duplicate of a pipe end and of an event's wait side,
watches each, and closes the other handle reads no answer on either.
2 changes: 1 addition & 1 deletion issues/cpal-backend-hardcodes-the-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ other format is refused.

**Client liveness waits on the same fork, not on soundserver alone.** The
ambiguity between a paused and a wedged client is designed in: pausing is the
client's stream thread simply not reading its signal pipe, with no
client's stream thread simply not taking its signal event, with no
coordination of any kind, and the cpal backend's `pause()` is a purely local
store soundserver is never told about. No change confined to soundserver can
separate the two, and landing the soundserver and SDK halves alone would kill
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ opened: 2026-10-03

`tests/guest/rust-tests/src/bin/hda_client_stall.rs` hands cpal an error
callback that prints `audio error: …` and does nothing else. The fork reports
two things through it: a stream whose signal pipe went away, and a dropped
two things through it: a stream whose signal event went away, and a dropped
stream soundd did not let go of within its fade and a ring of periods. Either
leaves the job exiting 0, and the `hda_client_stall` T14 row reads the job's
exit code. `tone.rs`'s `play_tone` asserts on the same callback.
Expand Down
23 changes: 0 additions & 23 deletions issues/mio-s-toyos-waker-never-drains-its-pipe.md

This file was deleted.

3 changes: 2 additions & 1 deletion issues/netstack-moves-every-byte-through-two-kernel-pipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ The design and its roast are PR #863's body.
in loom.
- Stage 3, done (PR #905): a TCP stream, connected or accepted, is one
region whose two rings are [tcp]'s buffers and the connection it was opened
on, which then carries only doorbells and the hang-up; std, libc, mio and
on, which then carries only exports; its doorbells and its holders' leaving
are events; std, libc, mio and
socket2 reach it through `toyos::net::stream`, and `SYS_CONNECTION_JOIN` is
gone. A stream is shared with a child as a holder of its own; other
sockets are not yet (`issues/a-socket-libc-or-socket2-has-not-bound-is-not-shared-with-a-child.md`). A sent byte is copied
Expand Down
5 changes: 3 additions & 2 deletions issues/no-physical-memory-fairness.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ divide an eighth of physical memory by what one unit costs and refuse past it,
and a pipe takes memory for its bytes as it holds them rather than at
`create`. Their
memory is counted to whichever job made it: a client's own connection is
its job's, but a pipe a server makes and hands out (fileserver's stream
pipes, netstack's bells) is the server's, which the server bounds. With no
its job's, but a pipe or an event a server makes and hands out
(fileserver's stream pipes, netstack's bells) is the server's, which the
server bounds. With no
per-job limit a cap is still the only thing between one client and the
machine.

Expand Down
21 changes: 11 additions & 10 deletions issues/null-sink-applies-one-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ shape is the same in both and it is narrow:
- soundd finds no device and presents the null sink; `null sink idle` prints.
- The first client connects. The control thread prints `opening stream`, the
**mix loop** prints `client 0 connected (id=0)` — so it woke, drained the
command pipe and applied the command.
command pipe (an event since #1108) and applied the command.
- Nothing from the mix loop ever again. `stats.report` fires every 2 s while a
client is present and the connect resets that window, so a single missing
stats line is proof the loop stopped; boot7's client was a 2 s tone and no
line follows it.
- Every later client is stranded: the control thread keeps running (it printed
`opening stream` for doom 14 s later) and `open_stream` answers, but no
`client N connected` follows, so the mixer never signals it and it blocks
reading its signal pipe forever. That is `tone` never exiting and doom
wedging with a black window before its render loop.
reading its signal pipe (an event since #1108) forever. That is `tone` never
exiting and doom wedging with a black window before its render loop.

**Not reproduced in QEMU.** `tests/desktopaudiocase` was built for this: the
T14's shape, with the client's three descriptors as pipes to a terminal that is
Expand All @@ -41,11 +41,12 @@ with a terminal opened afterwards.
**Eliminated, each with a run behind it.** CPU count. The cpal client path
(`null_sink_shipped_client`, adopted from `wt/toyos-hdaprobe` `fa47241`, two
`/system/bin/tone` in series at 1.16 s and 1.15 s). soundd blocking on a client
(`signal_clients` uses `write_nonblock`; there is no blocking write in the mix
loop). The accept path being held by a stuck client (accept and mix are
separate threads and the control thread ran for 14 s afterwards). A CQ overflow
(`Poller::wait`'s `dropped` assertion would have killed soundd, and soundd is
alive). A panic anywhere in soundd, for the same reason.
(`signal_clients` used `write_nonblock`, and an event's signal never blocks;
there is no blocking write in the mix loop). The accept path being held by a
stuck client (accept and mix are separate threads and the control thread ran
for 14 s afterwards). A CQ overflow (`Poller::wait`'s `dropped` assertion
would have killed soundd, and soundd is alive). A panic anywhere in soundd, for
the same reason.

**Eliminated by reading, and recorded so it is not re-derived.** The mix loop
holds no lock across its wait, and neither `PIPES` nor `IO_URINGS` was held on
Expand All @@ -54,8 +55,8 @@ open its stream. The mixer's timeout while streaming is finite (one device
period), so a park with that deadline is the only way to stop, and the timer
that ends it is the same one the compositor's frame interval rides. The
closed `io_uring::cancel_by_source` lost wake is *not* this: the mix loop's only
registration is on the command pipe, which soundd owns both ends of and nobody
closes.
registration is on the command pipe, now an event, which soundd owns both
sides of and nobody closes.

**What settles it is where soundd's mix thread is parked and on what
deadline, and nothing in the tree reads the deadline off a running T14.** soundd's mix thread parked with a sane
Expand Down
147 changes: 147 additions & 0 deletions kernel/pure/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! An event's state: one coalescing pending bit, and whether each side still
//! has a holder.
//!
//! A signal sets the bit and a take clears it; any number of signals before a
//! take are one. A wait side whose every signaler has gone is ready for good,
//! but a take there answers [`Taken::Gone`], never a signal nobody raised.

#![forbid(unsafe_code)]

use core::sync::atomic::{AtomicU8, Ordering};

const PENDING: u8 = 1 << 0;
const SIGNALERS_GONE: u8 = 1 << 1;
const WAITERS_GONE: u8 = 1 << 2;

/// What a signal found.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Signaled {
/// The event was idle and is pending now: its waiters are owed a post.
Raised,
/// It was already pending, so this signal coalesced into that one.
Coalesced,
/// No wait side is left to take it.
NoWaiter,
}

/// What a take found.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Taken {
/// A signal was pending, and is taken.
Signal,
/// Nothing pending, and a signaler may still raise one.
Empty,
/// Nothing pending, and no signaler is left to raise one.
Gone,
}

pub struct State(AtomicU8);

impl Default for State {
fn default() -> Self {
Self::new()
}
}

impl State {
pub const fn new() -> Self {
Self(AtomicU8::new(0))
}

/// Raise the event. Release, so what the signaler wrote before it is seen
/// by the take that answers [`Taken::Signal`].
pub fn signal(&self) -> Signaled {
let was = self.0.fetch_or(PENDING, Ordering::AcqRel);
if was & WAITERS_GONE != 0 {
Signaled::NoWaiter
} else if was & PENDING != 0 {
Signaled::Coalesced
} else {
Signaled::Raised
}
}

/// Take the pending signal, if any.
pub fn take(&self) -> Taken {
let was = self.0.fetch_and(!PENDING, Ordering::AcqRel);
if was & PENDING != 0 {
Taken::Signal
} else if was & SIGNALERS_GONE != 0 {
Taken::Gone
} else {
Taken::Empty
}
}

/// Whether a wait on the event answers now: a signal is pending, or no
/// signaler is left. Observes; takes nothing.
pub fn ready(&self) -> bool {
self.0.load(Ordering::Acquire) & (PENDING | SIGNALERS_GONE) != 0
}

/// The signal side's last handle has gone.
pub fn end_signalers(&self) {
self.0.fetch_or(SIGNALERS_GONE, Ordering::AcqRel);
}

/// The wait side's last handle has gone.
pub fn end_waiters(&self) {
self.0.fetch_or(WAITERS_GONE, Ordering::AcqRel);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn signals_before_a_take_are_one() {
let event = State::new();
assert_eq!(event.signal(), Signaled::Raised);
assert_eq!(event.signal(), Signaled::Coalesced);
assert_eq!(event.take(), Taken::Signal);
assert_eq!(event.take(), Taken::Empty);
assert_eq!(event.signal(), Signaled::Raised, "a taken event is idle again");
}

#[test]
fn readiness_observes_and_does_not_take() {
let event = State::new();
assert!(!event.ready());
event.signal();
assert!(event.ready());
assert!(event.ready());
assert_eq!(event.take(), Taken::Signal);
assert!(!event.ready());
}

/// A pending signal is answered before the signalers' end, and the end
/// after it is never read as a signal.
#[test]
fn signalers_gone_is_ready_but_never_a_signal() {
let event = State::new();
event.signal();
event.end_signalers();
assert!(event.ready());
assert_eq!(event.take(), Taken::Signal);
assert!(event.ready(), "an event with no signaler left stays ready");
assert_eq!(event.take(), Taken::Gone);
assert_eq!(event.take(), Taken::Gone);
}

#[test]
fn an_end_with_nothing_pending_is_gone_at_once() {
let event = State::new();
event.end_signalers();
assert!(event.ready());
assert_eq!(event.take(), Taken::Gone);
}

#[test]
fn a_signal_with_no_waiter_left_says_so() {
let event = State::new();
event.end_waiters();
assert_eq!(event.signal(), Signaled::NoWaiter);
assert_eq!(event.signal(), Signaled::NoWaiter);
}
}
1 change: 1 addition & 0 deletions kernel/pure/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod clock;
pub mod cpu;
pub mod dirnodes;
pub mod dmaguard;
pub mod event;
pub mod frames;
pub mod iova;
pub mod message;
Expand Down
Loading