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
8 changes: 2 additions & 6 deletions build/sourcegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ const AUTO_TRAIT_IMPLS: &[(&str, usize)] = &[
("kernel/src/arch/x86_64/hw.rs", 1),
("kernel/src/mm/mmio.rs", 2),
("kernel/src/mm/region.rs", 2),
("kernel/src/pipe.rs", 1),
("kernel/src/process.rs", 1),
("kernel/src/sched/driver.rs", 2),
("kernel/src/symbols.rs", 2),
Expand Down Expand Up @@ -680,14 +679,11 @@ mod tests {
}

/// A page a process can write while the kernel is inside it never becomes a
/// slice, and never an exclusive reference. Two files, named rather than
/// slice, and never an exclusive reference. The file is named rather than
/// walked: both spellings are ordinary elsewhere.
///
/// A *shared* reference is not banned: `Ring::header` soundly takes one over
/// the same page, its whole subject being an `AtomicU32`.
#[test]
fn no_slice_or_exclusive_reference_is_built_over_a_mapped_page() {
const OVER_A_MAPPING: &[&str] = &["sdk/abi/src/ring.rs", "kernel/src/user_ptr.rs"];
const OVER_A_MAPPING: &[&str] = &["kernel/src/user_ptr.rs"];
const SLICES: &[&str] =
&["from_raw_parts", "from_raw_parts_mut", "from_ptr_range", "from_ptr_range_mut"];
const EXCLUSIVE: &str = "&mut *";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
status: open
kind: defect
opened: 2026-10-28
---

# A 64 KiB pipe write is 2 % below the 2 MiB ring

Pipe bytes live in 4 KiB frames (`kernel/pure/pipe.rs`, `kernel/src/pipe.rs`)
instead of one physically contiguous 2 MiB page. On the T14 (NVMe boot, 4188
MHz), three interleaved boot pairs of the frame head against the ring base
read `pipe.throughput.65536` p50 7596 / 7547 / 7521 MB/s against 7616 / 7806
/ 7811. Every pair is lower (rank test p = 0.05, the floor for 3 against 3), a
mean of −2.4 %, with base's own boot-to-boot spread 2.5 %. 4096 and 16384 are
at base, and `pingpong.pipe.*` p50 is lower (better) on the head in every pair.

It is not per-piece overhead: a 16 KiB write crosses as many user-page
pieces per KiB and is at base, so a frame-aware walk that saves instructions
per piece is not expected to recover it.

The unmeasured candidate: a 64 KiB write spans 16 or 17 scattered frames
where the ring was physically contiguous, which a prefetcher that follows into
the adjacent page could use. The earlier image with physically adjacent frames
(PR #1074's evidence r6, diag4) was read while instructions dominated the gap,
on the old boot path at 3789 MHz, so it does not settle this.

Exit: a PMU session (the measurement-only counters of evidence r6) on the
landed head at 65536 beside base and beside a physically-adjacent-frames
image, then a fix or a folded note at `kernel/src/pipe.rs` stating the
measured cost.

Evidence: PR #1074, evidence r7 (the final A/B) and r6 (the PMU session).

This file was deleted.

43 changes: 23 additions & 20 deletions issues/a-pipes-lock-spans-a-user-copy-and-its-first-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,43 @@ kind: defect
opened: 2026-08-28
---

# A pipe's lock spans a 2 MiB user copy, and a pipe's first write allocates and zeroes 2 MiB under it
# A pipe's lock spans a 2 MiB user copy and the frames it takes for it

Each pipe owns its state behind its own `sync::Lock` (`kernel/src/pipe.rs`),
which disables preemption before it spins (`kernel/src/sync.rs`). The bulk copy
runs inside that lock:

* `PipeRef::try_read_within` runs `ring.read(len, |off, src| buf.write_run(off, &src))`
under it;
* `PipeRef::write` runs `ring.write(n, |off, dst| buf.read_run(off, dst))` in
the same acquisition.
* `PipeRef::try_read_within` makes one walk of the caller's window,
`buf.write_pieces`, and copies each pinned piece out of the frames it
covers with `bytes.peek`, under it;
* `PipeRef::write` makes one walk, `buf.read_pieces`, and copies each piece
into its frames with `bytes.place`, in the same acquisition.

The copy is bounded only by the ring: `PIPE_SIZE` is `PAGE_2M` less the 64-byte
`RingHeader`, so **2,097,088 bytes** is the largest single copy under the lock,
and nothing above caps it (`SYS_READ`/`SYS_WRITE` pass the caller's length
through). Each 16 pages of it take the caller's space lock and populate any
untouched page, a frame from the allocator and a 4 KiB zeroing.
The copy is bounded only by the pipe: `PIPE_CAPACITY` is 2 MiB, the largest
single copy under the lock, and nothing above caps it (`SYS_READ`/`SYS_WRITE`
pass the caller's length through). Each 16 pages of it take the caller's space
lock and populate any untouched page, a frame from the allocator and a 4 KiB
zeroing.

A pipe's **first** write is worse: `State::back` allocates the ring page lazily
under the same lock, nesting the frame allocator's lock and a 2 MiB zeroing.
A write that grows the pipe also takes its frames under the same lock,
nesting the frame allocator's lock: up to 513 frames, unzeroed, under one
hold, and the heap the list of them grows into. A read that empties frames
past the spares kept gives them back under the same nesting.

What queues behind it is that one pipe's traffic: its other end's reads and
writes, `has_data`/`has_space` from its blocking wrappers and polls, and its
handles' clones and closes. The scheduler's own bar for an uninterruptible
stretch is `MAX_PASS_NS = 200_000` ns (`kernel/pure/sched/cpu.rs`); a 2 MiB copy,
and on first write a 2 MiB zeroing, is past it at any plausible bandwidth. The
hold time is not measured, and that is owed first. The length is the caller's.
stretch is `MAX_PASS_NS = 200_000` ns (`kernel/pure/sched/cpu.rs`); a 2 MiB copy
is past it at any plausible bandwidth. The hold time is not measured, and that
is owed first. The length is the caller's.

## Fix direction

The lock is also the serialization `Ring` relies on (`sdk/abi/src/ring.rs`'s
doc), so the copy cannot simply be lifted out: a claimed-range token per end,
or a copy bounded per acquisition, has to carry the ring's data-race argument.
The lazy allocation moves out from under the lock: allocate before taking it,
or drop and retry on the miss.
The lock is also the serialization `kernel::pipe::Bytes` relies on, so the
copy cannot simply be lifted out: a claimed-range token per end, or a copy
bounded per acquisition, has to carry the data-race argument. The frames can
be taken before the lock and given back after it, as the read end's last
holder already gives back what the pipe held.

**Exit**: no pipe lock is held across more than a bounded copy or any
allocation, the hold time is read before and after, and `audio_tone`'s worst
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ What is not shared:

Each child descriptor naming a stream is a holder of its own: inetd's
`dup2(s, 0); dup2(s, 1); dup2(s, 2)` makes three holders for one child, each
a place, a watch and four pipe pages
(`issues/a-pipe-is-a-two-mebibyte-page-for-a-mapping-nothing-makes.md`). One
a place, a watch and four pipes. One
holder shared by a child's duplicates would need the child to alias an
adoption, since a second copy of one exported end carries no `ADOPT`.

Expand Down
3 changes: 2 additions & 1 deletion issues/no-physical-memory-fairness.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ netstack's piped connections, and `SYS_CONNECT` pinning 4 MiB into an
unbounded pending queue — and all three have a bound *and* a caller that hears
the refusal: `toyos_desktop::max_windows` and netstack's `places_for` each
divide an eighth of physical memory by what one unit costs and refuse past it,
and a pipe allocates its ring page on first use rather than at `create`. Their
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ only where it agrees with a separate stress of the real copy path.

`build/sourcegate.rs`'s `no_slice_or_exclusive_reference_is_built_over_a_mapped_page`
is the precedent for preferring a compile-or-gate answer here: it closed the
boundary's version of this question over `sdk/abi/src/ring.rs` and
`kernel/src/user_ptr.rs`, and the defect record it closed is already deleted
boundary's version of this question over `kernel/src/user_ptr.rs` and the
pipe ring the kernel then kept, and the defect record it closed is already deleted
from this tracker.
Loading