Repository navigation
ipc: a frame and its handles travel as one message; the standalone handle send is retired - #973
Conversation
ac32663 to
96d307a
Compare
|
Metal, T14 (ThinkPad T14 Gen 2), head
Logs stay in the readback directories; the implementer publishes them to evidence pr-973. |
… and the FrameRx release control (red as predicted), staged at tree e4b868d7d; masked
Review r1: PR #973 at
|
…e0ca3af37a7a: --ci host, both builds, 12 QEMU rows, host controls, metal staging incl. the kernel read control (not run); masked
|
Review r1, round 2, at
Checks:
Evidence: https://github.com/ToyOSOrg/evidence/tree/34e2c0c2a9e376d6952737f116e02eb104766d31/r2 |
r2:
|
|
Metal, T14 (ThinkPad T14 Gen 2), head
|
…send is retired A handle could be queued on a connection beside its bytes (SYS_HANDLE_SEND), and a connection's readiness was its bytes alone, so a server waiting for bytes never saw handles sent with none and could not refuse them. Two connections sent over each other with no bytes kept each other open after their client died, leaking every file-server description of a client and its share's served slot. SYS_MESSAGE_SEND writes a message's bytes and moves its handles together or not at all. Every handle's TRANSFER, the connection's, duplicates and the connection itself are checked before anything moves; handles need bytes; the whole message is refused when the peer holds MAX_QUEUED_BATCHES or is gone, and waits for room in the stream unless it asks not to. The batch is pinned at the stream position just past its bytes (kernel::message): a read never crosses a pinned batch, and stops there answering HandlesPending, and SYS_HANDLE_RECV takes a batch only where it is pinned. So a batch arrives with its frame and nowhere else. The work is the caller's: one bounded copy (MAX_MESSAGE_LEN) under the receiver's queue lock and then the pipe lock, which every reader takes in that order too. The SDK frame header carries the frame's handle count, and every frame is one message: send_with and its kin carry handles in the frame they belong to, a reader takes exactly the announced batch with the frame, and FrameRx closes what its caller does not claim. A read that meets a batch no frame announced is Malformed, and the connection is dropped. All 16 send sites move to it; SYS_HANDLE_SEND, Connection::send_handles and the handles-before-the-frame rule go. kernel::message's host tests check the stream model: a batch is taken only where its bytes end, no read crosses it, a refused send leaves nothing behind, and 20,000 random sends and reads keep every batch between its own bytes and the next message's. The guest binaries message_carries_its_handles (a frame's handle arrives with it, handles alone and the retired number 103 are refused with the server left idle, an unannounced batch stops the reader and dies with the connection) and ends_sent_over_each_other_both_end (the cycle breaks) replace handle_send_wakes_poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a parallel build A pipe write takes only the ring's free space and answers a short count, so while a reader lags even a 40-byte write is split and another writer's bytes can land between its parts. POSIX keeps writes of at most PIPE_BUF whole, and libc declares no PIPE_BUF. The exit, whole writes up to PIPE_BUF, changes the kernel's write semantics, so it is the owner's call; pipe::write_whole already does it for messages. A parallel build whose jobs each stream to their own log can spend a share's 16 streams. Raising STREAM_SHARE doubles the uncharged rings a share pins, so it waits for a measured build or for kernel memory charging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es sized to their payload SYS_MESSAGE_SEND refuses a flag bit other than MESSAGE_NONBLOCK with InvalidArgument, as every flag-taking syscall does, so a flag can be added later without changing what existing calls mean. Batches::admit answers only what the queue can refuse (handles without bytes, a full queue); room and the reader are the stream's, answered by pipe::write_whole, and the model test checks its own room. Holders::writable takes the bytes a write needs at once, so a byte write's readiness and a message's wait are one definition (pipe::has_space(id, want)) and pipe::has_room goes. A frame whose payload is at most 64 bytes, every typed payload and signal, is built in a buffer of that size instead of zeroing 8 KiB per send. Unused Batch::len and is_empty go, and the comments that still said a handle crosses ahead of its frame or that a refused try_send can leave part of a frame are corrected. The guest test no longer probes the retired number in inline assembly: the dispatcher's unknown-number arm shows it. The quotation in a-moved-handle-is-always-re-movable is restored. The PIPE_BUF issue states the change's costs (writes up to PIPE_BUF wait for room for the whole write; a non-blocking one answers WouldBlock instead of a short count) and why only the pipe can provide it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing bounds a typed payload to it, so it no longer says every one fits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…7fe469f1e554 (rebase onto #974): --ci host, both builds, console_image_boots; and round 2 T14 readbacks; masked
b705b26 to
f8d5123
Compare
#973 sends a frame whole or not at all, and its Full already means nothing went. That was all this branch's TrySendError::Busy said, so Busy is gone again: ipc.rs takes main's version, the four callers this branch had widened are main's once more, and usbd's input port answers Full with Sent::Full. This branch's code sends no handles outside the frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)
Draft. Implements the owner's ruling on review r4's S1: one kernel call writes a message's bytes and moves its handles together, all or nothing, and the standalone handle send is retired. This supersedes the earlier readiness fix and the frame-first rule that was to accompany it; neither is in this branch.
Problem
SYS_HANDLE_SENDqueued handles on a connection beside its bytes, and a connection's readiness was its bytes alone. A server waiting for bytes never saw handles sent with none and could not refuse them. Two connections sent over each other with no bytes kept each other open after their client died. Between two file-server directory connections, that leaks every description the client held and a share's served slot. Making queued handles readable instead (this PR's first version) would let any client spin every poll-driven server with one stray handle, because readiness is level-triggered.Resulting behaviour
SYS_MESSAGE_SEND(133) is the only way a handle crosses a connection. It takes bytes plus a handle list:WRITEon the connection (andTRANSFERwhen it carries handles),TRANSFERon every handle, no duplicates and not the connection itself, all before anything moves. Handles need bytes (InvalidArgument).MAX_QUEUED_BATCHES(ResourceExhausted) or is gone. It waits for room in the stream as a write does, or answersWouldBlockwithMESSAGE_NONBLOCK. The work is one copy of at mostMAX_MESSAGE_LEN= 16 KiB, charged to the caller.The batch is pinned at the stream position just past its bytes (
kernel/pure/message.rs, with the pipe's new monotonicwritten/readcounters):HandlesPending(11) there;SYS_HANDLE_RECVtakes a batch only at the reader's position;So a batch exists only with its frame, and a message's bytes wake the reader as any write does. Readiness changes in one place: a connection also reports readable while a batch waits at the reader's position, because a read there answers
HandlesPendingat once rather than blocking.MessageSend::flagsbits areInvalidArgument.SDK:
[msg_type][len][handles](12 bytes), and every frame is onemessage_send.send_with,signal_with,send_bytes_withand theirtry_forms carry handles in their frame.TrySendError::Fullnow means nothing was sent. A payload of at most 64 bytes (every typed payload and signal) is framed in a buffer of that size, not an 8 KiB one.recvrefuses a frame that carries handles, andFrameRxcloses what its caller does not take (take_batch,take_handles::<N>). A batch no frame announced makes the readMalformed, and the server drops the connection.The other converted surfaces:
HandlesPendingtoInvalidData, and libc toEPROTO.SYS_HANDLE_SEND,Connection::send_handles,recv_bounded,peek_widthand the handles-before-the-frame rule.Checks
Round 3 (head
f8d512350, rebased ontod5713917d, #974): #974 took 131 and 132 for its DMA calls, soSYS_MESSAGE_SENDis 133 andSYSCALL_PROFILE_BINSis 135;SMALL_PAYLOAD's doc is reworded. The metal and QEMU evidence atb705b266ccarries over: the rebase changes only that number and brings in #974's DMA code, which none of this branch's paths touch. Rerun atf8d512350as the smoke check:cargo run -- --ci hostcargo run -- --build-only(x86_64),--arch aarch64console_image_boots(every boot's file-server HELLO carries its window in its frame)Round 2 (review r1's fixes, head
b705b266c; basee62ebc084):cargo run -- --ci hostda07292cc+ the fixes, i.e.b705b266cbefore its issue-text editcargo run -- --build-only(x86_64),--arch aarch64cargo test -p kernel --lib --features sched-check message::b705b266cBatches::windowignores pins (patch applied, built, restored)b705b266cadmittakes handles without bytesb705b266ca_refused_send_leaves_nothing_behindredb705b266cThe QEMU rows are
console_image_boots nvme_disk_boots desktop_without_x2apic the_compositor_drives_the_virtio_gpu a_window_is_drawn_at_the_scale_and_relaid_at_the_next hda_plays_to_the_controller_that_reaches_an_output libc_children a_launch_gives_each_unsent_stdio_slot_a_null machine_shutdown_wire_held machine_shutdown_wire_kept a_drives_row_holds_one_process_per_function the_opening_exchange_with_qemus_agent_completes. They cover the converted protocols QEMU can reach: the fileserver HELLO, disk and blockserve, compositor and window, soundserver and hda, launcher, logkeeper and shutdown, and the agent exchange. Netstack has no QEMU row; its frames are covered by host tests and by the metal shared boot.Round 1 (
96d307a12): the same checks, all 0, plus the T14 results posted on this PR: fix 11/11 across 3 boots; theFrameRx::releasecontrol red as predicted. Round 2 changes the send path, admission and the write-room check those images exercise, so they are re-staged below rather than reused.Evidence (masked): round 3 https://github.com/ToyOSOrg/evidence/tree/ac745feb80c2acddb48ae074b6b078220f6c01f6/r3 and round 2's T14 readbacks https://github.com/ToyOSOrg/evidence/tree/ac745feb80c2acddb48ae074b6b078220f6c01f6/r2/metal-run; round 2 https://github.com/ToyOSOrg/evidence/tree/34e2c0c2a9e376d6952737f116e02eb104766d31/r2, round 1 and its T14 readbacks https://github.com/ToyOSOrg/evidence/tree/34e2c0c2a9e376d6952737f116e02eb104766d31/r1
Metal at
b705b266c(T14; carries over to round 3)The new guest binaries are discovered Rust tests, and those run only in the T14's shared boots; QEMU has no shared Rust block.
Fix, 3 images:
message_carries_its_handles,ends_sent_over_each_other_both_end, and the convertedprocess_tree spawn_lands_claimed cpal_drop_unreleased abuse_shared_grant mutual_kill shm_release_reclaims pipe_other_end_gone stream_handles netstack_gone_mid_bind.Results (readbacks in evidence
r2/metal-run/): fix 11/11 passed across 3 boots; the kernel control failed onlymessage_carries_its_handles, at "the reader went past a batch no frame announced", withends_sent_over_each_other_both_endgreen, as predicted.Kernel control, 1 image:
HandleQueue::readpasses a window that ignores pins. An SDK reader of an announced frame reads exactly its header and then its payload, so it lands on the pin anyway, and the image boots and serves as usual. Expected:message_carries_its_handlesred at "the reader went past a batch no frame announced" (part 3, where the second pump answersIdle), andends_sent_over_each_other_both_endgreen.Decisions and risks
writeon a connection still exists. It carries bytes only: doorbells use it, and it cannot attach a handle. A pin uses the end position its own whole write returned, so an interleaved plain write cannot misplace a batch.Malformed. The batch stays pinned, so the reader cannot pass it; dropping the connection is the only bounded answer, whichever side caused it (review suggestion S2, declined).SyscallError::HandlesPendingis new. The ABI is unstable, and every consumer in the tree is converted.🤖 Generated with Claude Code