Skip to content

fileserver: an open file is a description the server keeps, and an export shares it with another process - #975

Merged
Japabu merged 2 commits into
mainfrom
wt/toyos-fd
Oct 21, 2026
Merged

Japabu merged 2 commits into
mainfrom
wt/toyos-fd

Conversation

@Japabu

@Japabu Japabu commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, PR 1 of 4 in the descriptor work (fd-design.md revision 8). This one makes a served open file a description that the server keeps and that another process can share. The SDK fd table (PR 2), libc on it (PR 3) and inheritance (PR 4) stack on it.

Problem

A served file's offset lived in the client: std's Served kept it beside the fid. Two processes could not share one open file as POSIX does, and nothing could hand an open file to a child.

Resulting behaviour

  • Descriptions (system/fileserver/src/descriptions.rs): a description is the node, the access an open fixed, and the offset. A connection names descriptions by id. READ and WRITE act at the description's offset and move it; an append writes at the end and leaves the offset there. SEEK (21) moves the offset. DUP (20) makes a second id sharing it.
  • Streams: a stream holds its description and writes at its offset. Before any request that reads or moves that offset, the server drains what the description's streams hold, at most a ring's worth, so a child writing continuously cannot hold the loop. A node closes with its description's last id or stream, so Volume::hold has no caller and is deleted.
  • EXPORT (22): makes a connection for another process holding the descriptions of the named ids, as ids 1.. in order.
    • Self-connect: the server connects to itself through a private port only it holds a connector to. Connect, then accept, which answers exactly that connection on its single-threaded loop.
    • Accounting: the export takes its served slot when it is made, from the asking connection's share, and is refused TooManyConnections there. A full share is therefore the parent's refusal, never a dead descriptor in a child. The connection counts as served until its HELLO, which is not counted again, and no handshake bound or 2 s timeout applies to it. A grandchild's export is charged to the same share.
    • No path: a path request on an exported connection is refused NoDirectory (32; 29 to 31 stay retired).
  • SDK and std: toyos::fs::Dir reads, writes and seeks at the server's offset. It gains dup, seek, export and adopt, which serves an exported connection; an adopted Dir never reconnects. std's served File keeps no offset of its own, and its try_clone still shares one Served within the process.

Checks

Round 2 (8dde655b3, review r1's fixes): the export count is bounded before anything is read (bounds::export_ids); the exported end is narrowed to drop DUP; FSTAT and FSYNC settle streams too; and the stream-ordering rule is tested. All checks reran at the committed head: host base 65 pass; 4 host controls each red on their own test; --ci host 0 (88 steps); both builds 0; 7 QEMU rows 7/7. Evidence: https://github.com/ToyOSOrg/evidence/tree/ce61cd599ae0536817f27b43363e2341996d2922/r2

Round 1 (tree of 337e74c0a, base b48aab819); its T14 run was 6/6 PASS:

check exit
cargo run -- --ci host 0 (88 steps)
cargo run -- --build-only, --arch aarch64 0, 0
cargo test -p fileserver --lib 0 (64 tests)
negative control: dup makes a fresh description 101, the shared-offset test red
negative control: export never refuses 101, the export-bound test red
negative control: an exported HELLO counted again 101, the not-counted-again test red
QEMU x86_64 console_image_boots nvme_disk_boots desktop_without_x2apic libc_children a_launch_gives_each_unsent_stdio_slot_a_null machine_shutdown_wire_held a_drives_row_holds_one_process_per_function 0 (7/7)

Evidence (masked): https://github.com/ToyOSOrg/evidence/tree/053d57458587f7225368bd0e4b46edf0d3beabf4/r1

Metal (staged, not run)

The new guest binary an_export_shares_its_descriptions is a discovered Rust test, so it runs only in the T14's shared boots.

  • What it checks: the exported connection says hello; reads, writes and seeks through either connection move one offset; a path on the export is refused NoDirectory; the description outlives the exporter's id; and 1 MiB given to a stream comes before a write through the id, which lands at pattern length + 4, where the file ends.
  • Round 2 control: an image whose settled drains nothing. Expected: red at "the write did not land after what the stream was given".
  • Staged with it: the existing served-file binaries file_mtime app_view std_fs std_fs_write fs_truncate_persist, in 2 images.

What it establishes that host tests cannot is the server's composition of the self-connect, the exported HELLO and the shared offset across two connections.

Risks

🤖 Generated with Claude Code

…port shares it with another process

A served file's offset lived in the client (std's Served kept it beside
the fid), so two processes could not share one open file as POSIX does,
and nothing could hand an open file to a child.

A description (fileserver::descriptions) is the node, the access an open
fixed and the offset; a connection names descriptions by id. READ and
WRITE act at the description's offset and move it (an append writes at
the end and leaves the offset there), SEEK moves it, and DUP makes a
second id sharing it. A stream holds its description and writes at its
offset, and before a request that reads or moves the offset the server
drains what that description's streams hold, at most a ring's worth, so
a child writing continuously cannot hold the loop. A description's node
closes with its last id or stream, so Volume::hold has no caller and
goes.

EXPORT makes a connection for another process holding the descriptions
of the ids named, as ids 1.. in that order. The server connects to itself
through a private port only it holds a connector to: connect, then accept,
which answers exactly that connection on its single-threaded loop. The
export takes its served slot when it is made, of the asking connection's
share, and is refused TooManyConnections there, so a full share is the
parent's refusal and never a dead descriptor in a child; the exported
connection counts as served until its hello, which is not counted again,
and no handshake bound or timeout applies to it. An export from an
exported connection is charged to the same share. The connection names
no path: a path request is refused NoDirectory (32; 29 to 31 stay
retired).

toyos::fs::Dir reads, writes and seeks at the server's offset, gains
dup, seek, export and adopt (serving an exported connection, which never
reconnects), and std's served File keeps no offset of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Metal, T14 (ThinkPad T14 Gen 2), head 337e74c0a, run by the orchestrator: the proctreecase and testcases images both booted (rc 0). The judge reported 6 passed, 0 failed (an_export_shares_its_descriptions, file_mtime, app_view, std_fs, std_fs_write, fs_truncate_persist).

@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #975 r1: head 337e74c0a on b48aab819

Architecture

The ownership is right and follows revision 8. The server owns the description (node, access, append, offset). A connection names descriptions by id, and DUP, EXPORT and a stream share one description through Rc. Every holder of a node is now one description, so the volume's per-node count needs no second holder and Volume::hold goes away without a replacement. EXPORT uses the server's private self-connect: one client path, no kernel change, nothing POSIX-specific. That matches ruling M. Q9 and Q10 are not touched. Positional I/O has no caller after std drops its offset, so leaving it to PR 3 with its caller is correct. Keeping try_clone as a shared Arc<Served> instead of DUP is simpler than the plan and has the same meaning, because the clones share one id and one description.

Size: about +480/-200 production lines, plus about 130 test lines (descriptions host tests, bounds tests and the new guest binary). Most of the addition is the new descriptions.rs and the export path in main.rs.

What I verified by reading the code:

  • Offsets and append. READ moves the offset by n. WRITE and stream writes go through one write_at; an append writes at the file's size and leaves the offset at the end. bounds::seek refuses a seek before the start, past MAX_FILE_BYTES, or one that overflows, and a host test covers it. An unknown whence is InvalidRequest. std's SeekFrom::Start above i64::MAX is refused before it reaches the wire.

  • Draining streams first. settled drains every stream on the description. Each drain reads up to 64 KiB, at most 32 turns, so 2 MiB per stream. Ring::read returns min(want, available) and handles the wrap in the same call (sdk/abi/src/ring.rs:207). The ring holds 2 MiB minus its header (kernel/src/pipe.rs:102). So 32 full reads take every byte that was in the pipe when the request arrived, and the loop stops early only at WouldBlock or when the stream ends. The ordering holds.

  • Per-request work. Every stream on a description belongs to one share, because an export keeps the opener's share and STREAM charges the asking client's share. So one request drains at most STREAM_SHARE × 2 MiB = 32 MiB, all of it that share's own pending writes. Normal round-robin gives a stream 64 KiB per turn. That is a bounded priority boost, not starvation, and draining what is present is what the ordering requires. I accept it.

  • Slot accounting (R1/R2). served() counts window.is_some() || exported. EXPORT checks the asking connection's share and takes the slot by inserting the client before it replies. HELLO with Slot::Taken skips the count. waiting() keeps exported clients out of the handshake cap and the 2 s timeout. Every exit removes the client from the map, so its count goes with it:

    • The reply send fails: move_batch closes the end we handed out, so our end reaches EOF.
    • The receiver dies before HELLO: its handle closes, and that is EOF.
    • The SDK gets a malformed answer: it closes the end.

    A grandchild's export is charged to the same share. Correct.

  • NoDirectory (32). root: Option<String>, and path() and resolve() are the only readers, so the type forces every path request on an exported connection to answer NoDirectory. Leaving 29 to 31 retired follows the earlier decision recorded in a_reply_outside_the_protocol_is_malformed. Fine.

  • The Volume::hold deletion. OPEN calls volume.open once per description. DUP, EXPORT and STREAM only clone the Rc. release closes the node when the last id or stream lets go, including a stream that ends while settled holds a clone (the id still holds it then). Correct.

  • SDK. Source::{Named, Exported} makes "an adopted Dir never reconnects" a property of its type. export handles a malformed batch the same way stream does. Fine.

Findings

BLOCKER 1. system/fileserver/src/main.rs:883: a client can panic the file server.
usize::try_from(count).ok().filter(|&n| n * 8 <= WINDOW_BYTES) takes count straight from the client's r.len. [profile.toyos] sets overflow-checks = true (Cargo.toml:356), so an EXPORT with len >= 1 << 61 panics on the multiplication and takes down the server for every client of that role. Root CLAUDE.md says "a userland bug never crashes" a server, and external input is refused at its boundary.

Fix: check the count first, before any copy. Requiring 1 <= count <= MAX_IDS also keeps the copy at 8 KiB instead of 2 MiB. One way is to move the parse into bounds (for example bounds::export_ids(len)) and add a host test with u64::MAX, 1 << 61, 0, MAX_IDS and MAX_IDS + 1. That is the cheapest sufficient check.

BLOCKER 2. system/fileserver/src/main.rs:893-908: the exported end is not narrowed, which departs from the agreed design.
Revision 8 §1 Export says the sent end is narrowed without DUP "so 'one holder' holds by construction". §1 Bounds' "its holder bounds it" rests on that. The fix costs two calls and needs no new API: toyos_abi::syscall::dup_narrowed(theirs, rights.without(Rights::DUP)), then drop the original.

The PR's Risks entry understates what is lost. A holder can do more than keep the slot longer: it can make several holders, in several processes, of one served connection. They share one lent window and one turnstile-less request stream, and that connection's served slot is held for as long as any of them lives. TRANSFER stays, because handing the end to a child is a move, so one holder at a time is still possible.

"Rights can be narrowed" does not by itself require narrowing. The requirement comes from the approved design, and the cost is negligible. This is a dropped part of the agreed design, not a routine implementation choice. It should be fixed here, or the deviation should go back to the owner with its reason. If something in the SDK turns out to need DUP on the received end, that is the discovery to report.

BLOCKER 3: nothing tests the stream-before-offset rule.
This is the PR's main POSIX claim ({ echo a; cmd; echo b; } > f keeps its order), and no host test, guest test or negative control exercises it. settled lives in main.rs, which is not host-testable. If it stopped draining, or Rc::ptr_eq matched the wrong description, nothing would go red.

The cheapest sufficient check runs in the same shared boot by extending an_export_shares_its_descriptions, with no child process:

  1. Call parent.stream(fid, generation).
  2. Write about 1 MiB of a known pattern into the returned pipe, which fits in the ring without blocking.
  3. Immediately call parent.write(fid, generation, b"tail").
  4. Seek and read back: the tail must sit after the whole pattern, and the reply's offset must be pattern length + 4.

The negative control is to remove the drain loop in settled. Then at most a couple of 64 KiB turns have run before the WRITE is served, so the red is deterministic, not a race. Adding a SEEK_END assertion in the same step covers the size half too.

SUGGESTION 1. system/fileserver/src/main.rs:494-496 and Client::served: exported: bool repeats root.is_none().
Every exported client has root: None and no other client does, so the two fields can disagree. One field would make the state single, for example enum Origin { Granted { root: String }, Exported }, or exported() derived from root.

SUGGESTION 2. system/fileserver/src/main.rs:455 (STREAM_PRESENT): the ordering depends on a private copy of the kernel's pipe size.
If kernel/src/pipe.rs:102 PIPE_SIZE ever grows, the ordering guarantee breaks without any error. netstack has the same private copy (system/netstack/src/main.rs:100). A PIPE_BYTES in toyos-abi would serve both, since SYS_PIPE_MAP already makes it part of the kernel/program boundary. Alternatively, leave it and record the dependency.

SUGGESTION 3. system/fileserver/src/main.rs:407-411: a speculative clause in the module header.
"(with a second thread, one lock covers the pair)" describes an imagined design. The invariant that matters is "single-threaded, so the accept answers that connect", and it is already stated. Delete the clause.

SUGGESTION 4: FSYNC (and FSTAT) do not settle.
A parent that calls sync_all after a child streamed into the description syncs without the bytes still in the pipe. That is outside revision 8's offset rule and unchanged from base, so it is optional. If you want it, settling in FSYNC is one line.

Evidence notes, not findings:

  • checks.sh hashes git diff only. The tracked changes match the commit exactly (566e954d… equals git diff b48aab819 337e74c0a without the two new files). The two new files, descriptions.rs and the guest binary, are not covered by that hash. Next time, hash with git add -N or git stash create.
  • The three mutation controls are real and are reds on host tests. The QEMU set never runs EXPORT, because the new binary is metal-only. So until the T14 results arrive, the self-connect, the exported HELLO and the cross-connection offset have run on no target. The pending T14 run is the evidence for them, and its result should be added to the PR.
  • A cheap optional addition to the same guest binary: export until TooManyConnections, drop every end without HELLO, then export the same number again. That shows the pre-HELLO release end to end. Reading the code already establishes it, so this is not required.
  • pkg streams a package into /apps within its volume's room, and packages the toolchain #970's ROOM = 20 renumbers when pkg streams a package into /apps within its volume's room, and packages the toolchain #970 rebases. No action here.

Verdict

Description sharing, offset and append semantics, the 2 MiB per-stream drain bound, slot accounting, NoDirectory, the Volume::hold deletion and the SDK additions are correct. Three blockers remain: the client-triggerable overflow panic, the dropped narrowing from the agreed design, and the untested stream-ordering rule.

SEND BACK

…a stream's bytes come before the write after them

An EXPORT's id count is the client's word, and multiplying it by eight
overflowed and panicked the server for every client of its role.
bounds::export_ids refuses a count outside 1..=MAX_IDS before anything is
read.

The end an export hands out is narrowed to a connection's rights less DUP,
so whoever holds it is its one holder, as the design has it: it may still
pass it on.

FSTAT and FSYNC settle the description's streams first, as READ, WRITE
and SEEK do, so a size or a sync after a child streamed counts what it
wrote. Whether a client was exported is its having no directory, one
field rather than two that could disagree.

an_export_shares_its_descriptions gains the ordering the stream rule
promises: a megabyte given to a stream and then a write through the id
lands after all of it, and the file ends there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Oct 21, 2026
…5c85ebb7dd5a: host controls, --ci host, both builds, 7 QEMU rows, metal staging incl. the drain control; masked
@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1, round 2, at 8dde655b3:

  • B1: bounds::export_ids refuses a count outside 1..=MAX_IDS before anything is read, so the copy is at most 8 KiB. A host test covers 0, MAX_IDS + 1, 1 << 61, u64::MAX, 1 and MAX_IDS. The control (the count unbounded) is red on it.
  • B2: the end an export hands out is narrowed with dup_narrowed to a connection's rights less DUP (TRANSFER | WAIT | READ | WRITE | MAP), and the original is dropped. Nothing in the SDK duplicates a received connection end, so nothing needed DUP back.
  • B3: an_export_shares_its_descriptions now opens a stream on a fresh file, puts 1 MiB of a pattern into the pipe, and writes tail through the id. It asserts the reply's offset is pattern length + 4, that SEEK_END lands there, and that the last pattern bytes come just before tail. The metal control image removes the drain loop from settled; expected red at "the write did not land after what the stream was given".
  • S1, taken: exported() is root.is_none(); the separate field is gone.
  • S2: STREAM_PRESENT's doc now names the kernel's PIPE_SIZE it depends on. A shared ABI constant is left for whoever next touches netstack's copy.
  • S3, taken: the clause is deleted.
  • S4, taken: FSTAT and FSYNC settle too.
  • Evidence: every check this round ran at the commit itself, so the hash question doesn't arise.

Checks, all at the committed head:

  • host base 65 pass, and 4 host controls each red (101) on their own test, restored;
  • --ci host 0 (88 steps), both builds 0, 7 QEMU rows 7/7.

Metal is restaged, 2 fix images and 1 drain control, not yet run. Evidence: https://github.com/ToyOSOrg/evidence/tree/ce61cd599ae0536817f27b43363e2341996d2922/r2

@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Metal, T14 (ThinkPad T14 Gen 2), head 8dde655b3, run by the orchestrator from a clean detached worktree:

  • Fix: the proctreecase and testcases images both booted (rc 0). The judge reported 6 passed, 0 failed.
  • Control (the drain removed): an_export_shares_its_descriptions FAIL (101) with "the write did not land after what the stream was given", as predicted.

@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

r2: head 8dde655b3 (diff from 337e74c0a: 3 files, +78/-18)

I checked only the r1 fixes and the code they changed.

B1: resolved. bounds::export_ids (system/fileserver/src/bounds.rs:96-100) refuses any count outside 1..=MAX_IDS before the window is read or any multiplication runs. So the overflow is gone and the copy is at most 8 KiB. The host test covers 0, MAX_IDS + 1, 1 << 61, u64::MAX and both edges. Its control (export-count-unbounded) is red with 101 and restores to 0 (fd1r2/checks.txt).

B2: resolved. Exports::connect narrows the end it hands out with dup_narrowed to EXPORTED, which is TRANSFER | WAIT | READ | WRITE | MAP (system/fileserver/src/main.rs:87,194-201). It drops the full-rights original before returning. I checked EXPORTED against the kernel: a connection's initial rights are BASE (DUP|TRANSFER|WAIT) | READ | WRITE | MAP (kernel/src/object/ops.rs:32,37-39). So EXPORTED is exactly that set less DUP, a strict subset, and the narrowing cannot be refused as a widening. TRANSFER stays, so the end can still be moved to a child, and the reply's send keeps the TRANSFER it needs. The narrowing can fail with ResourceExhausted. Then the error goes back to the parent as OutOfResources before the server's end is registered, and both ends close, so no slot leaks.

The set is a hand-copy of the kernel's connection rights, because the SDK has no call that reports a handle's rights. If the kernel's set ever drops one of these bits, the first export would panic through Error::kernel. That failure is loud and this guest test would catch it, so it is not a finding.

B3: resolved; metal pending. The new section of an_export_shares_its_descriptions works as follows:

  1. It opens a fresh file and a stream on it, and puts 1 MiB of a known pattern into the pipe. That is half the ring, so it never blocks.
  2. It then writes tail through the id.
  3. It asserts the reply's offset is 1 MiB + 4, that SEEK_END lands there, and that the pattern's last two bytes come immediately before tail.

The control (settled-drains-nothing.patch) removes exactly the drain loop. With the drain gone, the server has done at most a few 64 KiB turns by the time the WRITE is served. So the control's red is far from the threshold, not a race. A metal control image is staged in fd1r2/control-readback. The T14 fix and control results are the remaining evidence for this.

Suggestions:

  • S1, taken: exported() is now root.is_none(), so only one field holds that state.
  • S3, taken: the clause is deleted.
  • S4, taken: FSTAT and FSYNC go through settled.
  • S2, declined with a recorded dependency: the doc now names the kernel's PIPE_SIZE. That is acceptable.
  • The header's new sentence, "The end handed out cannot be duplicated, so it has one holder", is accurate and short.

Evidence: fd1r2/checks.txt records head 8dde655b3 with an empty status before and after. Every line is exit 0: --ci host, both builds, the 7 QEMU rows, and 4 host controls each red then restored. As expected, the fix and control stages exit 2, because they are staged only.

No new findings.

LAND, once the T14 fix boot is green and the drain-control boot is red at "the write did not land after what the stream was given". The orchestrator holds those results.

@Japabu
Japabu marked this pull request as ready for review October 21, 2026 13:32
@Japabu
Japabu added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit c778264 Oct 21, 2026
8 checks passed
@Japabu
Japabu deleted the wt/toyos-fd branch October 21, 2026 13:59
Japabu added a commit that referenced this pull request Oct 21, 2026
Kept this branch's deletions of the kernel xHCI, irq_ring, log recovery
and the scheduler's Operation framework, and with them #976's per-CPU
fields for irq_ring and the no-task operation slot. fileserver takes
#975's descriptions without the partition claims this branch removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-merge-queue Bot pushed a commit that referenced this pull request Oct 21, 2026
Implements the owner's decision (Jan, 2026-10-21, claude-questions item
10): small pipe writes are indivisible as ordinary ToyOS pipe behaviour.
Closes
`issues/a-pipe-write-of-a-few-bytes-can-tear-between-two-writers.md`.

## Problem

`pipe::try_write` wrote `min(len, free space)`. Whenever the reader
lagged, a 40-byte line could split, and another writer's bytes could
land between its two parts. Pipe writers are separate processes that
share no lock, so only the pipe can keep a write whole.

## Resulting behaviour

- **The bound.** A pipe write of at most `MAX_WHOLE_PIPE_WRITE` = 4096
bytes (`toyos-abi`) lands whole or waits until there is room for all of
it. The non-blocking form lands whole or answers `WouldBlock`. Longer
writes still land as far as they fit, as before. libc declares the bound
as `PIPE_BUF` 4096 in `limits.h`, and a libc host test
(`pipe_buf_is_the_kernels_whole_pipe_write`) holds the two equal. A
compile-time assertion in `kernel/src/pipe.rs` keeps the bound within
the ring (2 MiB less its header).
- **One write function.** `kernel::pipe::Landing`
(`kernel/pure/pipe.rs`) says how a write lands: `Whole` (a message),
`Pipe` (whole up to the bound) or `Partial` (a connection's plain
write). Its `room(len)` is the room the write waits for, and
`takes(space, len)` is what it writes; both come from the same
comparison. `crate::pipe::write(id, buf, landing)` replaces both
`try_write` and `write_whole`. #973's message send is now the same write
with `Landing::Whole`, so the whole-write mechanism is reused rather
than duplicated.
- **Size-aware waiting.** `sys_write` waits on `has_space(id,
landing.room(len))` instead of one byte. A waiting writer wakes only
when its write will land. Every read and the reader's departure still
post the write watch, which wakes every waiter to check its own
condition again. No new wake path exists, and none is missing.
- **Readiness.** A pipe's write end polls writable only when
`MAX_WHOLE_PIPE_WRITE` bytes are free. A non-blocking write that was
refused and is retried on readiness therefore lands instead of spinning.
- **Empty writes wait for nothing.** `Holders::writable` no longer
raises the room it wants to one byte. A zero-byte write to a full pipe
or connection answers 0 (or `Gone` with no reader) instead of parking.
- **Unchanged.** Byte counts, `Gone` / `ResourceExhausted` errors,
files, consoles and devices, and the connection's plain write
(`Landing::Partial`) behave as before.

## Decisions

- **Connections are excluded.** The approval covers pipes, not sockets,
so a connection's plain write stays partial and its writability stays
one byte. Messages on a connection were already whole. Extending
`Landing::Pipe` to connections is a one-line change if the owner wants
it.
- **4096 bytes.** That is the common `PIPE_BUF`, above POSIX's 512
minimum. It is far below the 2 MiB ring, so a whole write always fits
eventually, and readiness costs little.
- **No guest test** (review r1 agrees). The behaviour lives in the pure
`Landing`/`Holders` functions, which the host tests exercise. The kernel
glue around them is three call sites, held by inspection and not by any
test or control: `sys_write` passes `room`, `ops::has_space` passes the
bound, and `sys_write_nonblock` already maps a refused write to
`WouldBlock`. The QEMU smoke run shows the write path is not regressed.
It does not establish that the size-aware wait or readiness behave as
designed; those rest on inspection plus the host tests.
- **Declined review suggestions:** S1 (`Landing::ready_room()`) would
need a meaningless arm for `Whole`, which no object polls; S2 (renaming
`Landing::Pipe`) is kept as is, since its doc states the behaviour.

## Checks

Round 2 (head `1fcbfd9d2`, rebased onto `e74dbf183`, after #975 and
#976). Review r1's fixes: stale `pipe::try_write` names fixed in
`kernel/loom/tests/inbox_answer.rs` and
`issues/the-global-pipe-lock-spans-a-user-copy.md`; the `PIPE_BUF`
check; and one clause in `kernel/pure/pipe.rs`'s header.

| check | exit |
|---|---|
| `cargo run -- --ci host` | 0 (88 steps) |
| `cargo run -- --build-only` (x86_64), `--arch aarch64` | 0, 0 |
| control c7: `PIPE_BUF` set to 512, `cargo test -p toyos-libc-copies
pipe_buf` | 101; restored 0 |

Round 1 at `584d4f246` (the pipe code is unchanged since, apart from a
module comment, so its results carry over):

| check | exit |
|---|---|
| `cargo run -- --ci host` | 0 (88 steps) |
| `cargo run -- --build-only` (x86_64), `--arch aarch64` | 0, 0 |
| `cargo test -p kernel --lib --features sched-check pipe::` | 0 (12
tests, 7 new) |
| QEMU x86_64 `console_image_boots libc_children` (stdio pipes,
`spawn_pipe_eof`, `spawn_pipe_reader_gone`, `popen`) | 0 (2/2) |
| negative controls c1–c6, each applied, built, run and reverted | each
builds (0) and reds (101); restored 0 |

New host tests in `kernel/pure/pipe.rs` model the pipe's write, wait
condition and poll readiness over the real `Landing` and `Holders`:
- whole-or-wait at every amount of room, with a long write still
partial;
- a refused non-blocking write is answered by readiness, and the retry
lands;
- empty writes;
- the reader leaving answers a waiting writer, and the write is refused;
- wake-iff-lands, checked exhaustively over room, length and landing;
- three writers with a lagging reader: 1042 writes are refused for room,
every record arrives intact and every writer finishes.

The same competing run under `Partial` tears, which is the test's own
control.

Controls:
- c1 is the old partial landing.
- c2 is the old `want.max(1)`.
- c3 is a `writable` that ignores the reader.
- c4 mutates `Holders::writable` to want at most one byte. Its effect
equals the old one-byte wait (a busy wake), but it does not mutate the
`sys_write` glue.
- c5 wants one byte more than needed (a missed wake).
- c6 mutates only the test model's poll, to one byte. It shows the
test's sensitivity to a busy retry, not a product property.

No control can catch a revert of the `io.rs` `room` argument or the
`ops.rs` bound; those are held by inspection. Red test names are in
`controls/summary.txt`.

Evidence (masked): round 2
https://github.com/ToyOSOrg/evidence/tree/afbfa442ca959f2a73f339b8dfb6c62189410a3c/r2,
round 1
https://github.com/ToyOSOrg/evidence/tree/afbfa442ca959f2a73f339b8dfb6c62189410a3c/r1

## Risks

- **No order among waiting writers.** A whole write of up to 4096 bytes
can keep losing freed room to smaller writes that fit first. Starving it
would require the ring to stay within 4 KiB of full under continuous
competing writes. This is not measured, and POSIX promises no fairness
here either.
- **Writability now needs 4 KiB free rather than one byte.** A poller
that would have written a few bytes into a nearly full pipe now waits
until 4 KiB is free. No in-tree program relies on writing to a pipe that
is almost full.
- **The glue is held by inspection only.** The three kernel call sites
have no test or control; the QEMU smoke run shows only that the write
path is not regressed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Japabu added a commit that referenced this pull request Oct 21, 2026
The owner asked whether everything self-hosting needs is written down.
toyos-builds-itself.md held it in five lists in an order it called open:
what to build, what other tracks block, what stops M2, what M3 adds and
what else is owed. It is now one list in dependency order: each item has
its status, the issue that owns it and what blocks it, and the evidence it
rests on is below it, with libc's revision for the compiles.

The status words say only what is true: "direction set" is the owner's
ruling of what an item must do, with its design still engineering's. The
memory and socket designs, which are outside the tree and under review, are
named as such, and so is the sequencing the socket design proposes for the
descriptor work's spawn.

Folded in:
- C reaches served files (#975, #978, #988);
- #970's toolchain package;
- the memory direction;
- the process track's stages 4 to 7;
- socket sharing;
- the boot handoff, beside the path;
- the database feasibility study's libc findings;
- the issues that name this track as their owner, among them pread, libc++'s
  std::filesystem, and the two that keep a build from reproducing.

Each owner ruling of 2026-10-21 is recorded once, in its owning issue:
- the signal scope in the process track;
- sharing rather than moving, in the socket issue, whose exit described a
  move;
- the database rulings in a new track, split from the planning
  dispositions made under delegation.

Four findings get a home:
- unlink-while-open;
- libc's uid and gid;
- sigaction's false success;
- st_nlink 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Oct 21, 2026
The owner asked whether everything self-hosting needs is written down.
toyos-builds-itself.md held it in five lists in an order it called open:
what to build, what other tracks block, what stops M2, what M3 adds and
what else is owed. It is now one list in dependency order: each item has
its status, the issue that owns it and what blocks it, and the evidence it
rests on is below it, with libc's revision for the compiles.

The status words say only what is true: "direction set" is the owner's
ruling of what an item must do, with its design still engineering's. The
memory and socket designs, which are outside the tree and under review, are
named as such, and so is the sequencing the socket design proposes for the
descriptor work's spawn.

Folded in:
- C reaches served files (#975, #978, #988);
- #970's toolchain package;
- the memory direction;
- the process track's stages 4 to 7;
- socket sharing;
- the boot handoff, beside the path;
- the database feasibility study's libc findings;
- the issues that name this track as their owner, among them pread, libc++'s
  std::filesystem, and the two that keep a build from reproducing.

Each owner ruling of 2026-10-21 is recorded once, in its owning issue:
- the signal scope in the process track;
- sharing rather than moving, in the socket issue, whose exit described a
  move;
- the database rulings in a new track, split from the planning
  dispositions made under delegation.

Four findings get a home:
- unlink-while-open;
- libc's uid and gid;
- sigaction's false success;
- st_nlink 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Oct 21, 2026
The owner asked whether everything self-hosting needs is written down.
toyos-builds-itself.md held it in five lists in an order it called open:
what to build, what other tracks block, what stops M2, what M3 adds and
what else is owed. It is now one list in dependency order: each item has
its status, the issue that owns it and what blocks it, and the evidence it
rests on is below it, with libc's revision for the compiles.

The status words say only what is true: "direction set" is the owner's
ruling of what an item must do, with its design still engineering's. The
memory and socket designs, which are outside the tree and under review, are
named as such, and so is the sequencing the socket design proposes for the
descriptor work's spawn.

Folded in:
- C reaches served files (#975, #978, #988);
- #970's toolchain package;
- the memory direction;
- the process track's stages 4 to 7;
- socket sharing;
- the boot handoff, beside the path;
- the database feasibility study's libc findings;
- the issues that name this track as their owner, among them pread, libc++'s
  std::filesystem, and the two that keep a build from reproducing.

Each owner ruling of 2026-10-21 is recorded once, in its owning issue:
- the signal scope in the process track;
- sharing rather than moving, in the socket issue, whose exit described a
  move;
- the database rulings in a new track, split from the planning
  dispositions made under delegation.

Four findings get a home:
- unlink-while-open;
- libc's uid and gid;
- sigaction's false success;
- st_nlink 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant