Skip to content

Every server admits its clients through one table, toyos::admission - #1242

Merged
Japabu merged 6 commits into
mainfrom
server-admission
Nov 12, 2026
Merged

Japabu merged 6 commits into
mainfrom
server-admission

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Eight servers each kept the connections they had accepted and not yet served in a table of their own, with their own copy of a 2 s HANDSHAKE_TIMEOUT: the compositor, diskserver, filepicker, fileserver, logkeeper, netstack, the supervisor and usbd. issues/servers-let-a-client-go-on-the-handshake-limit-with-its-request-unread.md recorded what the copies got wrong: six let a connection go on the limit before reading what the same wait reported, two waited a fresh whole limit each pass, six accepted a connection only to close it at a full table, two said nothing of a drop, and the supervisor's stop waited in the launchers' table. Only the file server gave a client a share of its table.

What this does

toyos::admission (sdk/toyos/src/admission.rs) is that table once, and the eight servers keep none of the mechanism.

  • A connection is taken off its port with Admission::accept, enters with Admission::admit under the key its server reads off its badge, and leaves Admission::next as a Client holding its first whole request or as the reason it was let go. A first request is read from nothing but a Client, and a Client has no call that waits on its peer: next_request takes a whole frame or nothing, the replies are try_send*.
  • One key holds at most Policy::share of Policy::slots; its next connection is refused by name (Refused) while another key's is taken.
  • A connection is read before it is let go on its limit, so a request that is whole is served however late the pass. The server's wait is left: what remains of the oldest limit.
  • A full table accepts no more: accept takes a connection only while the table has room and otherwise leaves it in the port's queue. A watch on an acceptor stays armed until it fires, so leaving the acceptor unwatched is not enough; the rule is the module's, not each server's.
  • No client writes its server's log at its own rate. Of a key's refused or let-go connections, the table has its server say one line a key a limit for at most slots keys, and one more line a limit shared by all the keys past those (Refused::say, Event::Left's say); no key is said twice in a limit. Each refusal or connection let go that is not said is counted into the next line said. A count no line has carried within a limit of the last is said on its own (Event::Unsaid, in the server's voice) on the shared line, and left wakes the server for it. So a table says at most slots + 1 lines a limit however many keys arrive, says or counts every refusal and connection let go in a line within a limit of the line before, and never says a hang-up.
  • The limit is one declaration, admission::LIMIT, 20 s: Apache httpd's RequestReadTimeout header default, cited there with nginx's 60 s. It is no Policy field: every table's is the same. The compositor's display-output driver's hello deadline took the same constant, so it too went from 2 s to 20 s.

Per server, policy and key are data:

server table key share
fileserver hellos (bounds::HANDSHAKES, 32) the grant's share a quarter, as before
netstack requests (32) badge: row and session a quarter; refused ERR_RESOURCE_EXHAUSTED
netstack NIC drivers' hellos none whole table
supervisor launcher, grants, login, admin, swap (32) badge: row and session; login's the row alone; swap's none a quarter
supervisor power (8), a table of its own none whole table
diskserver clients (16) badge: the grant a quarter; refused Exhausted
diskserver drivers not yet named (16, counted with the named against MAX_DRIVERS) driver badge one
compositor compositor first frames and a copy's commit (32) none whole table
compositor prompt, clipboard, display (3) the port one each (replaces toyos_desktop::pending_admits)
filepicker callers and the supervisor's questions (32) the port half each
logkeeper log's readers (8) none whole table
usbd inspect's readers (8) none whole table

What a client can and cannot do afterwards

  • Fill only its share, where its badge names it: a file server, netstack, the supervisor's badged ports. Past it the next connection is refused by name and another key's is taken (a_key_at_its_share_is_refused_while_another_key_is_taken).
  • A silent client costs its own slot for the limit and is then let go unanswered; one that hangs up is noticed by the read that would have taken its request; one that sends what the protocol cannot describe is let go at once (a_client_that_never_sends_its_request_…, a_frame_no_protocol_describes_and_a_hang_up_each_free_their_slot).
  • A slow reader is refused its reply in the one send and holds nobody (a_client_that_reads_no_reply_is_refused_it_and_holds_nobody).
  • Every wait is the server's own poll, bounded by left; the table itself waits on nothing.
  • Fill no table past full: a connection past a full table waits in the port's queue (a_full_table_accepts_nothing_and_its_port_keeps_the_connection).
  • Write its server's log only once a limit by being refused or let go (a_key_s_refusals_are_said_once_a_limit_with_the_count_between, a_key_s_malformed_frames_are_said_once_a_limit_and_hang_ups_never, a_key_said_on_the_shared_line_is_said_once_a_limit, past_its_slots_keys_a_table_says_one_more_line_a_limit_and_counts_the_rest, a_count_no_line_carries_is_said_on_its_own_a_limit_after_the_last_line, a_count_said_on_its_own_spends_the_shared_line). What a server says of a request it then refuses has no such bound yet: issues/a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md.
  • What it still can do: where a connector carries no badge naming its holder, every holder spends one key. On the Policy::unshared tables (the compositor's compositor port, logkeeper's log, usbd's inspect, netstack's link, the supervisor's power) one holder can hold the whole table for a limit at a time, and keep it full by reconnecting; on filepicker's, its port's half; on the supervisor's shared table, the quarter that swap's holders share. A program that holds several keys (sessions, or rows it launches) holds as many shares. Both are recorded, below.

The per-request path

Unchanged, by reading: the file server's pump/reply still read its own FrameRx on its own Connection and answer with one try_send; the diff touches neither. sdk/toyos/src/ipc.rs gains one #[cfg(test)] function. The table's frame buffer is inline in its entry, as each server's Pending held it, so a connection costs no allocation it did not; Client::next_request is FrameRx::pump and two stores. What does change is per connection, for the servers whose every request is a connection of its own (netstack, the supervisor, diskserver, filepicker, logkeeper's and usbd's readers): admit counts its key's entries (a walk of the entries) and finds a free slot (slots × entries, at most 32 × 32 comparisons), and woke finds the entry its token names (a walk of the entries), where the copies pushed onto a Vec and searched the ready list once per entry. A refusal or a let-go connection also walks the at most slots keys the table remembers lines of. That is an inference from the code, not a measurement; no metal A/B was run.

Records

  • Closed: issues/servers-let-a-client-go-on-the-handshake-limit-with-its-request-unread.md. Four of its exit items are not this change's and live on: issues/a-client-its-server-let-go-reads-that-as-no-server.md (the client libraries' word for a hang-up) and issues/netstacks-serve-module-has-no-tests.md, new; and soundserver's and the surface hosts' (read before letting go, and stop accepting at a full table), moved into the narrowed record below.
  • New: issues/a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md, the lines a server says of a request it refuses, which the admission bound does not reach.
  • Narrowed, still open: issues/one-program-that-fills-a-servers-table-holds-every-other-client-out.md now says which tables are shared by holder (login by its row alone), which by grant, and which have one share for all holders because their connector carries no badge, and that soundserver's control plane and the surface hosts are not on the mechanism: both keep a client past its first request, so what bounds them is a share of their served clients. Its exit is the outcome, no holder past its share of any table, and leaves the mechanism to the owner's decision below.
  • Not closed, and why:
    • four-shares-take-a-file-server: unchanged; four shares at their part still take a table.
    • netstack-lookup-slots-have-no-per-client-share, netstacks-places-are-one-number-for-every-client: shares of the node's lookups and places, not of the admission table. The key they ask for now reaches netstack on every connection (the badge); the node does not yet count by it.
    • one-diskserver-session-can-hold-every-device-slot: toyos_blockserve::Sessions::pull's device slots, after admission; the block session's track.
    • a-guest-close-after-a-refused-handshake-can-leave-its-peer-waiting: a TCP close during a TLS handshake with a host peer; not a server's admission at all.

Decision returned to the owner

Giving the unbadged ports a per-holder share means the supervisor mints every connector it hands out with its holder's row and session (and console and terminal mint their surface's), which changes what a badge says: the manual has "the kernel vouches for what was granted, never for who connects". Not done here; the record carries it.

Manual

docs/src/architecture/services.md gains Admission, which says the full-table rule and the bound on a client's lines.

Review r1

Review sent this back with four findings, each addressed in 602db29:

  1. A full table still accepted and closed in the supervisor and filepicker: Admission::accept now asks the room at every accept, every server accepts through it, the compositor parks each acceptor whose table was full, and the module docs say the rule.
  2. diskserver's driver bound: named and naming drivers now count together against MAX_DRIVERS, at the watch and at the accept, as its comment says, and its poller is sized for that again.
  3. The records' false statements are corrected.
  4. Refusal lines at the client's rate are bounded in the module, as above, and tested.

From the suggestions: Policy::limit is gone; the file server's two HELLO arms already share their logic, bounds::hello, and differ in how they answer, so they stay; the description's corrections are made above and in Risks.

Review r2

Review sent this back with three findings, addressed in 5d1abda and bc262f8:

  1. B1. The line bound broke past slots keys: the table evicted the key it had said of longest ago even within its limit, and its own test asserted the second line. It now remembers only keys said of within the last limit, at most slots, forgetting none early, and says one more line a limit for all keys past those. One table-wide count of the lines it did not say rides on the next it does. That is simpler than a count per key plus an overflow count: there is no eviction choice and no count left pending on a forgotten key. It keeps both properties, at most slots + 1 lines a limit and every line accounted for. The cost is that a count names no key. The module header, the manual and Risks say so, and the test is now past_its_slots_keys_a_table_says_one_more_line_a_limit_and_counts_the_rest.
  2. B2. logkeeper now says every reader it lets go but one that hung up, as the other seven servers do, and its false comment is gone.
  3. B3. The owed-on-metal record was already in bc262f8.

Review r3

Review sent this back on one finding, addressed in a7078c8 with the suggestions:

  1. R1. A count no later line carried was never written. While a count is owed, left now covers the moment a limit after the table's last line, when every line it said has run its limit. next ends that pass with Event::Unsaid and spends the shared line on it, so the bound stays slots + 1.
    • It is a new Event rather than a line the module writes, for three reasons. A host build's log discards what the module would write, so no host test could see it. The servers log through say!, println! or eprintln! under names of their own. And a new variant makes each of the eleven Event matches handle it.
    • Each server says the count in its own voice.
  2. S1. The shared line now remembers its key, so no key is said twice in a limit.
  3. S2. The test past the slots refuses slots + 2 keys in the next limit and expects slots + 1 lines.
  4. S3. logkeeper's header now says "never the log's loop".

Review r4

Review: LAND. Its three optional suggestions are taken in 833fd60:

  • S4. The comment where a pass says a count now gives the real reason the shared line is free.
  • S5. Unsaid's doc no longer ties it to a connection.
  • S6. The compositor's count line names its table, and a count of one reads "1 more connection".

Rebase onto main 2c8d8bb

The branch is rebased onto main 2c8d8bb, which brought #1235's block client sessions, #1216 and #1239's typed received handles.

At the head d5efd6c, with a clean worktree (logs), all exit 0:

  • cargo test -p toyos --lib admission, 18 of 18, and toyos's two clippy shapes with -D warnings.
  • diskserver's host tests, A block session never waits on its server's word alone #1235's included, and its clippy with the adopted lints, which found nothing.
  • logkeeper's host tests, since main also changed inspect.rs.
  • cargo check --all-targets of every host-buildable converted server and toyos-desktop.
  • cargo run -- --build-only (log), which compiles every program for the guest.

Guest run 43125079920, the same six rows at d5efd6c, is queued. Before the rebase, run 43099711330 passed all six at 833fd60.

Checks before the rebase

The evidence is on ToyOSOrg/evidence, branch pr-1242, last at 135f8e711cb1, and each link below names the commit its log was published in.

The pre-rebase head was 833fd60. It differs from a7078c8 only in one comment and one doc in sdk/toyos/src/admission.rs, the count's own-line text, and the compositor's count line. At the head (logs):

  • cargo test -p toyos --lib admission passed 18 of 18;
  • both of toyos's clippy shapes exited 0;
  • cargo run -- --build-only exited 0 (log).

The checks below ran at a7078c8 and hold for the head. Its negative controls mutate code 833fd60 does not change.

Review r3 changed:

  • sdk/toyos/src/admission.rs: the owed count and Event::Unsaid, and the keyed shared line;
  • one Event::Unsaid arm in each server's Event match;
  • logkeeper's header;
  • the manual's and one record's sentence on the bound.

At a7078c8

All exit 0 (logs):

  • cargo clippy -p toyos --all-targets and cargo clippy -p toyos --lib --features fd-table, with CI's adopted lints and -D warnings.
  • cargo test -p toyos, which includes admission's 18 tests.
  • logkeeper's host tests.
  • cargo clippy --all-targets of the six converted servers that build for a host, with the adopted lints as warnings. Every finding is older than this branch.

cargo run -- --build-only at the head exited 0 (log). It compiles every program for the guest, so every server's new arm compiles, the compositor's and the supervisor's included.

Negative controls: eleven checked patches of sdk/toyos/src/admission.rs, run at the head (patches and logs). Each was applied, built and run against cargo test -p toyos --lib admission, then restored in the same run. The baseline passes 18 of 18, and each patch fails at least the tests named:

  • m1-no-share lets a key past its share. a_key_at_its_share_is_refused_while_another_key_is_taken fails.
  • m2-drop-before-read lets a connection go on its limit unread. a_request_that_is_whole_is_served_on_a_pass_past_its_limit fails.
  • m3-fresh-limit waits a fresh limit. the_wait_is_what_is_left_of_the_oldest_limit fails.
  • m4-accept-past-a-full-table accepts without asking the room. a_full_table_accepts_nothing_and_its_port_keeps_the_connection fails.
  • m5-a-known-key-said-again says a key held in a slot again within its limit. The refusal-bound and malformed-frame tests fail.
  • m6-every-key-past-the-slots-said. The test past the slots fails.
  • m7-a-count-never-said-alone never says the count on its own. a_count_no_line_carries_is_said_on_its_own_a_limit_after_the_last_line fails.
  • m8-no-wake-for-a-count gives no wake for an owed count. The same test fails, on left.
  • m9-the-shared-line-forgets-its-key. a_key_said_on_the_shared_line_is_said_once_a_limit fails.
  • m10-the-shared-line-never-renews, review r3's S2 mutation. The test past the slots fails.
  • m11-a-count-spends-no-shared-line. a_count_said_on_its_own_spends_the_shared_line fails.

Guest rows

The six rows cover:

  • netstack's requests and both NICs' hellos: netstack_holders_exported_to_children;
  • netstack's link table taking a restarted driver's hello: a_link_ends_under_a_held_stream_and_comes_back;
  • the supervisor's login and launcher: session_lifecycle and a_launch_gives_each_unsent_stdio_slot_a_null;
  • the compositor's two tables: a_window_is_drawn_at_the_scale_and_relaid_at_the_next and the_opening_exchange_with_qemus_agent_completes.

No row reaches a full table or the line bound, which rest on the module's tests.

A green run keeps no serial logs, and its job log carries no server output. So whether a server said a let-go or refusal line for a test's own client cannot be read from a guest run; the owed metal boots' logs can show it.

No row or metal job runs filepicker. Its conversion rests on the module's tests, its host compile and lint, and the image build.

What still holds from 602db29, and why

cargo clippy -p toyos-desktop --all-targets (-D warnings) and cargo test -p toyos-desktop (logs): since then nothing toyos-desktop builds or calls has changed.

The host tests of diskserver, fileserver and netstack last ran at 602db29 (logs). Since then each server has gained only log arms in its admission loops, which compile and lint at the head.

The full cargo run -- --ci host at 942e9fa (log) still covers what this branch does not touch. Its one red outside the branch, llvm::tests::keyed_and_built, is recorded in issues/the-llvm-environment-test-breaks-git-on-macos.md, and build/ is unchanged.

Owed on metal

Both metal benches are down, and metal is no landing condition for this PR. What is owed is recorded in issues/one-program-that-fills-a-servers-table-holds-every-other-client-out.md:

  • Rows: the boots boot:testcases (its testcases and testcases-watchdog images) and boot:shared-debug.
  • Bench: the T14.
  • What main must show:
    • every job of those boots passes within twice its recorded time;
    • the supervisor, the file server, diskserver, logkeeper and usbd log no refusal of a share, a full table or a silence to a client of the boot's own;
    • diskserver logs disk driver … (label) connected for each driver.

The images were staged at 602db29 and are deleted (staging log).

Risks

  • The limit is 20 s where it was 2 s: a silent connection holds its slot ten times as long. With a full table now leaving the next connection in the port's queue rather than closing it, an unbadged port's other clients wait rather than fail.
  • The display-output driver's hello deadline is also 20 s where it was 2 s: a driver that never says hello holds the display port that long.
  • A key's line is said once a limit, so a second refusal of one key within a limit is counted rather than named. While a table has said of slots keys within a limit, all newer keys share one line a limit, so all newcomers but one are counted rather than named. A count says how many went unsaid, not whose, and is said at the latest a limit after the line before it.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1242 at 4d1fad6

Architecture

Admission belongs in the userland SDK. It is a mechanism shared by servers, not a kernel or ABI concern, and toyos_abi appears in it only for SyscallError and MAX_BADGE. Each server supplies its policy and its key as data, and the module holds no server-specific logic. The API, after the six unused methods were removed, is what the eight servers call. Event::Left returns the connection, key and context because the compositor and diskserver need them; Client::next_request exists because diskserver keeps its driver connections as Clients; and Badge::new exists for the supervisor's handed-over launches. The Line trait is a seam for host tests with one production implementation. That matches the root rule allowing host tests against models, and costs nothing at run time.

Net size: the module adds about 415 production lines and the servers lose 210, so production grows by about 200 lines. Tests grow by 249 module test lines, against about 40 removed server test lines (pending_admits and bounds::handshake). The behaviour those removed tests checked is now held by the module's share test, plus the compositor's separate OWNERS table, which inspection establishes. Records shrink by 65 lines.

The design is right. One property the PR claims is not true for the supervisor, and its documented contract is what misled it. Details are below.

Required findings

  1. BLOCKER: a full table still accepts, then closes the connection, in the supervisor (and filepicker). At system/supervisor/src/main.rs:1721-1735 the accept loop runs whenever an acceptor's token is ready, and does not check asking.has_room(). A kernel watch stays armed until it fires or is replaced (kernel/src/inbox/polls.rs; netstack's watches.rs documents the same). When asking fills, the watches already armed on the other four acceptors remain in force. A table can fill through one acceptor's accept, through several acceptors firing in one pass, or through pump_links admitting handed-over launches. When one of those still-armed watches fires, the loop accepts, admit refuses Full::Table, and the connection is closed unanswered. That is the closed record's forbidden case: "none accepts a connection to close it because the table is full". It also contradicts the manual's new "A full table accepts no more" and the PR body.

    • system/filepicker/src/main.rs:573 has the same shape with two acceptors. Its 16/16 split makes it practically unreachable there, but the pattern is the same.
    • The single-acceptor servers are correct, because the firing that fills the table consumes their only watch: netstack, diskserver's clients, and usbd. The file server, logkeeper and the compositor are correct because they track an armed or parked flag.
    • The cause is in the contract. sdk/toyos/src/admission.rs:331-332 and the header at :26-28 say a server "leaves its acceptor unwatched" while the table is full. With persistent watches that is not enough.
    • Fix: accept only while has_room(). In the supervisor, if !ready.contains(&token) || !asking.has_room() { continue; }, and likewise in filepicker. The queued connection re-fires when the acceptor is watched again. Correct both doc comments to state that rule.
    • While editing the header: :6-7 says "a [Client] is the only thing a request is read from". The file server reads every later request from its own FrameRx, so this holds only for the first request.
  2. BLOCKER: diskserver's driver bound is no longer the one its comment states. system/diskserver/src/main.rs:67 says "Disk drivers connected at once, each one connection, named or not yet", but the guard at :345 is self.drivers.len() < MAX_DRIVERS && naming.has_room(). Since naming entries do not count toward MAX_DRIVERS, there can be up to 15 named and 16 naming drivers, which is 31. A watch armed before drivers grows through naming can also push the count past 16. Before this branch the total was at most 16.

    • In practice this needs more than 16 driver badges minted by the supervisor, and the poller (50) holds the worst case of 49 watches, so nothing crashes.
    • Fix one of two ways: hold the stated bound (count naming entries toward it, checked at accept time), or state the real bound in the comment.
  3. BLOCKER: two statements in the changed records are false.

    • issues/a-client-its-server-let-go-reads-that-as-no-server.md:23: "Not seen since the limit became 20 s". The limit became 20 s in this PR, and no guest run has finished since. Delete the clause.
    • The same record, :19-21, says logkeeper and usbd close a share-refused connection "with a line in their own log". Logkeeper discards the refusal (let _ = readers.admit(..)). Neither table is shared, so neither can refuse a connection for its share.
    • issues/one-program-that-fills-a-servers-table-holds-every-other-client-out.md:19-20 puts login among the ports keyed "by the row and session". opener reads the login badge as the row name alone (main.rs:1815-1817), so every session of one login row shares a single key.
  4. BLOCKER: one log line per share refusal, at the client's rate, is recorded only in the PR body. A program at its share that keeps reconnecting writes a netstack, supervisor or diskserver line on every connect. That uses up the server's per-program allowance in logkeeper (origin.rs), so the server's own lines in that second are counted rather than written. Diskserver's share refusal is new with this branch; netstack and the supervisor already had this at a full table. Logkeeper's old comment rejected exactly this. Under "No silent debt", either bound it (for example, count refusals and say the count once per limit) or file it in issues/ with an owner and an exit. The PR body's "Risks" section is not a record.

Suggestions (optional)

  • Make the accept rule structural. For example, Admission::accept(&mut self, acceptor, now, key_of) would return None while the table is full. "A full table accepts no more" would then belong to the module rather than to each server's discipline. Two of eight servers got it wrong.
  • Drop Policy::limit. Every policy uses LIMIT, and the manual says the limit is the same for all. Removing the field also removes the limit: admission::LIMIT boilerplate in three servers and the test module's duplicate LIMIT const (admission.rs:424).
  • Share the file server's HELLO logic. The HELLO arm is now in two places, hello() at main.rs:563-601 and answer()'s exported arm at :761-773, differing only in Slot. One helper would serve both.
  • Corrections for the PR description (orchestrator's).
    • The closed record's two soundserver and surface-host exit items moved into the narrowed record; the body says only two items were filed elsewhere.
    • The compositor's display-output hello deadline also went from 2 s to 20 s (output.rs, which now uses LIMIT), and the body does not say so.
    • "admit … a walk of at most the table's entries" understates the slot search, which is slots × entries (at most 32 × 32). That is still negligible.
  • The narrowed record's exit names the undecided mechanism. Its exit still reads "keyed by a badge that names the holder", while its body says that change is the owner's to decide. Stating the exit as the outcome (one holder cannot take more than its share of any table) would not pre-empt the decision. No endowment-redesign record exists to link to.

The questions in the brief

The module.

  • Wait handling. Read before let-go, left() and the slot tokens are correct. A stale token aliasing a reused slot costs one spurious read and a re-arm, never a lost wake; I traced this through netstack's Armed as well.
  • The 20 s limit. It is what the closed record's exit prescribed, with both citations. It matters little against a persistent client: on unshared tables reconnecting keeps a table full at any limit, and shared tables cap each key regardless. What it does change is that a one-off silent burst, or a leaky client, holds slots ten times longer. The sharpest case is power: every toybox applet holds it, so a stop can now queue up to 20 s behind 8 silent power connections. That is acceptable and recorded in the narrowed record.
  • Refusal logging. See finding 4.

Each conversion. Beyond findings 1 and 2, I found no behaviour lost.

  • netstack. A hello with the wrong handle count is now refused Malformed rather than dropped silently, and silent drops are logged.
  • The supervisor. The separate power table is correct, and stopping is drained before asking. MAX_CHILDREN drops by 8 to make room for it. Handed-over launches are keyed by Badge::new, which correctly avoids the PermissionDenied an of call would get on another port's connection.
  • The compositor. The copy commit's re-admission is now bounded; before, it could grow past the poller budget. A let-go connection is closed before reap, which is safe because no client connection is allocated between the two.
  • Diskserver. Both drop paths now log. unnamed calls driver_ended as the old drop_driver did.
  • The file server. The share and refusal answers are unchanged, and the export path uses its own port.
  • Logkeeper and usbd. Each now waits only what is left of the oldest limit and logs only silent drops.
  • Filepicker. The modal session no longer drops requests that arrived whole meanwhile. The conversion holds by inspection apart from finding 1.

The badge lookup. The panic is sound.

  • sys_port_badge refuses with a bad handle (the server's own), InvalidArgument (no stamp, which an accepted end never has), PermissionDenied (another port's connection), or a fault on the server's own stack buffer.
  • Every Badge::of call passes the acceptor that just produced the connection.
  • A connection's stamp is fixed by the kernel at connect time, so no client can produce any of these.
  • Diskserver's old badge() already panicked the same way.

The four records left open. The reasoning is right for all four, and each would be wrong to close.

  • four-shares-take-a-file-server: the quarters are unchanged.
  • The two netstack records concern the node's lookups and places, which are past admission.
  • one-diskserver-session-can-hold-every-device-slot concerns blockserve::Sessions.
  • The TLS-close record concerns TCP, not admission.

Per-client shares on unbadged ports. The record is accurate about which tables are protected by holder, which by grant and which have one share for everyone, and about the minting gap. It assigns the badge decision to the owner. The login key is wrong (finding 3).

Overlap with #1239. #1239 makes FrameRx::take_handles and Batch::exact return ReceivedHandle, removes Connection::received, and edits handed_over. Batch::EMPTY survives, so FrameRx::load still compiles. Whichever PR lands second:

The six deleted methods. Confirmed. git diff 97b413994 4d1fad663 touches only sdk/toyos/src/admission.rs: it removes wipe, try_signal, try_send_with, try_signal_with, policy and is_empty, and three tests switch to left() or try_send_bytes. No server calls any of them; the two compositor try_signal calls are on a Connection.

Evidence

  • Host checks. I checked the head's runs, recorded as "97b413994 + worktree diff", against the commits: git diff 97b413994 4d1fad663 | shasum is 4b8bf36a8774 and git diff 9700737ed 97b413994 is 9cdc5d20cf02, both as recorded. Clippy (two shapes, -D warnings), cargo test -p toyos and the check of the six servers that build on a host all exit 0.
  • Negative controls. Each patch applied, failed exactly its guarding test, and was restored, against an 11/11 baseline.
  • Compositor build. The compositor was last built at 942e9fa. Nothing it calls changed since then (it uses Admission::next, not the renamed Client::next).
  • Unrelated red. The llvm::tests::keyed_and_built failure is recorded and the branch does not touch build/.
  • Guest run 43050908618. It is still queued, and its headSha is 97b4139, which is no longer on the branch.

The fixes for findings 1 and 2 touch only behaviour at a full table, which no row or boot reaches. If they are limited to that guard, the docs and the records, a compile at the new head is enough, and the queued guest run and the metal boots remain valid evidence.

Landing preconditions.

  • Guest run 43050908618, at 97b4139 or later: all six rows green. The job log must show the revision it checked out. The netstack, supervisor and compositor logs must contain no letting a connection … go or silent-drop line for the tests' own clients.
  • T14 testcases, testcases-watchdog and shared-debug at 4d1fad6 or later:
    • every job passes, and timings stay within twice their recorded values;
    • the supervisor, file server, diskserver, logkeeper and usbd log no share, table or silent refusal of a legitimate client;
    • diskserver still logs disk driver … (label) connected for each driver.

Verdict

SEND BACK. The four required findings are each a small change. Once they are fixed and the guest run and metal boots show the above, this is ready to land.

Japabu added a commit that referenced this pull request Nov 11, 2026
…s are said once a limit

A watch on an acceptor stays armed until it fires, so a server that stopped
watching its acceptor when its table filled still heard the watch it had
armed before, accepted, and closed the connection for the full table: the
supervisor, across its five acceptors and handed-over launches, and
filepicker across its two. `Admission::accept` now takes a connection only
while the table has room and leaves it in the port's queue otherwise, and
every server accepts through it. The compositor parks each acceptor whose
table was full and watches it again once there is room, where it had done so
for `compositor` alone; its `prompt`, `clipboard` and `display` would
otherwise have been watched again at once and fired again.

Every line a server said of a key's refused or let-go connection was one a
connection, at the client's rate, which spends the server's allowance in
logkeeper and counts its own lines that second rather than writing them. The
table now says one line a key a `LIMIT` (`Refused::say`, `Event::Left`'s
`say`) carrying how many went unsaid since, remembers at most `slots` keys,
and never says a hang-up. What a server says of a request it then refuses has
no such bound: `issues/a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md`.

diskserver held 16 drivers named plus 16 naming themselves where its comment
says 16 connected at once; the accept now counts both against `MAX_DRIVERS`
(`Admission::held`), and its poller is sized for that bound again.

`Policy::limit` goes: every table's limit is `LIMIT`, as the manual has it,
and `Left::Silent` carries nothing.

The records: the let-go record no longer claims a run since the 20 s limit,
and names which servers refuse for a share; the table record keys `login` by
its row alone, and its exit is the outcome rather than a mechanism the owner
has not chosen. The manual's admission section says both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
Both benches are down. The `testcases` (with `testcases-watchdog`) and
`shared-debug` boots that #1242's servers were staged for are recorded as
owed on the T14, with what main must show there, in the record the PR
already narrows.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1242 at 602db29

Architecture

The round-2 changes fit the design. Admission::accept moves the full-table rule into the module, and the eight servers now get it by calling the module rather than by each following a rule. Bounding the refusal and let-go lines belongs in the module too, because the table already knows each key and the clock. Queue is a test seam with one production implementation, the same pattern as Line, which round 1 accepted. It costs nothing at run time.

Size: the module's production code grows by 95 lines (415 to 510) and its tests by 97 (249 to 346). The servers change by +194/-147. The compositor removes the mark_dead path for waiting connections, which also removes the handle-reuse hazard round 1 traced. One new record has 32 lines.

Round-1 findings

  1. Full table accepted then closed: resolved.
    • Admission::accept asks for room at every accept. Every admission accept in the eight servers goes through it. The remaining direct Acceptor::accept calls are on ports with no admission table: the export ports, inputs, outputs and logkeeper's carrier.
    • The supervisor's five acceptors and power, and filepicker's two, return None while the table is full. I traced each server's watch: none re-arms an acceptor while its table is full, so a None cannot spin the loop.
    • The compositor parks a port whose table is full. drain runs on FRAME_INTERVAL, so a parked port is watched again within one frame of a slot freeing, and no wake is lost.
    • The header now says "first request".
  2. diskserver driver bound: resolved.
    • drivers.len() + naming.held() < MAX_DRIVERS is checked both at the watch and at the accept. A naming entry becomes a named driver one for one, so named plus naming never exceeds 16.
    • The poller declares 2 + 16 + 16, which covers the most registrations one round can make: two acceptors, at most 16 asking entries, and at most 16 naming or named drivers. The None arm of naming.accept cannot be reached under drivers_room.
  3. False record statements: resolved. The "not seen since 20 s" clause is deleted. The share-refusal sentence now names the tables correctly. login is keyed by its row alone.
  4. Refusal lines at the client's rate: partly resolved.
    • Per key, the bound holds. Hang-ups are never said. The remaining request-level lines are filed in a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md, which has an owner and an exit.
    • The bound fails once more keys than slots are active; see B1.

Findings

B1. BLOCKER: the line bound fails past slots keys, and the module's own test asserts the failure.

  • Locations: sdk/toyos/src/admission.rs:502-505 and :843-855; docs/src/architecture/services.md:37-41.
  • The module header (:22-26), the manual and the PR body all promise one line per key per LIMIT, so that no client writes its server's log at its own rate. But say evicts the remembered key said longest ago, even when that key was said within its limit. The evicted key's next event is then said again immediately.
  • The test shows it. In a_table_remembers_the_lines_of_at_most_its_slots_keys (slots 2), key a is said at 1 s and again at 4 s, inside one 20 s limit, and the test asserts that second line.
  • Consequence: with more than slots keys cycling, every event is said, at the clients' combined rate. That is round 1's finding 4 again, reachable by any slots + 1 holders of a shared table, or by one program holding that many keys. The narrowed record says a program can hold several keys.
  • Fix, either of:
    • Hold the bound. Evict only a key whose limit has passed. While every remembered key was said within its limit, a new key's line is not said. A table then says at most slots lines per limit, because each line said keeps its entry for its whole limit. The cost is that a new key is neither said nor counted while the table is saturated; state that cost where the bound is stated, and update the test.
    • Or state the real bound in the header, the manual and Risks: one line per key per limit only while at most slots keys are said of within it, and beyond that every event is said.
  • The first option is what the manual's rule needs, and it changes three lines.

B2. BLOCKER: logkeeper's new comment gives a false reason, and the code drops a line the table chose to say.

  • Location: system/logkeeper/src/inspect.rs:166-172.
  • The comment says a malformed reader goes unsaid because "every holder of log would write into it at its own rate". Under say, a malformed reader would be said at most once per limit, so the stated reason is false.
  • The code also discards a say: Some(..) that next returned for Left::Malformed. That uses up the key's line for the limit, so a Silent reader let go later in the same limit gets None and goes unsaid.
  • Fix: say every non-hang-up Left, as the other seven servers do, and delete the comment. Or keep the filter, correct the comment, and say that a malformed frame's line is dropped.

B3. BLOCKER: the metal checks this PR owes are not recorded.

  • The benches-down ruling makes metal no longer a landing condition. It also requires each check that only metal can establish to be recorded as an Owed on metal: bullet, in an issue record the PR updates or failing that in the area's record. No changed record has one.
  • Round 1's T14 conditions now have no home. The PR body also says the guest run cannot show whether a server said a let-go line for a test's own client, and that only the T14 logs can.
  • Add the bullet in, for example, one-program-that-fills-a-servers-table-holds-every-other-client-out.md, naming:
    • the rows: T14 boot:testcases (with testcases-watchdog) and boot:shared-debug;
    • the bench: T14;
    • what main must show: every job passes within twice its recorded timing; the supervisor, file server, diskserver, logkeeper and usbd log no share, table or silent refusal of a legitimate client; diskserver logs disk driver … connected for each driver.
  • This is a records-only change.

For the orchestrator (PR description)

  • "Guest rows … is queued": run 43066328896 has finished green.
  • "Metal, staged … its result is added here when it arrives": under the ruling, this becomes the owed list from B3.
  • Risks, third bullet: make it match the B1 resolution.

Evidence

My runs on a detached worktree at 602db29:

Check Result
cargo clippy -p toyos --all-targets and -p toyos --lib --features fd-table, CI's six adopted lints, -D warnings exit 0
cargo test -p toyos exit 0; lib 196 passed, including admission's 15
cargo test --manifest-path system/{diskserver,fileserver,logkeeper,netstack}/Cargo.toml all exit 0
cargo clippy of diskserver, filepicker, fileserver, logkeeper, netstack and usbd, adopted lints as warnings all exit 0

The six servers' clippy reported 11 findings. git blame puts every one on a commit older than this branch.

Negative controls. They ran at d09c3e470. git diff --stat d09c3e470 602db29e9 touches only system/compositor/src/session.rs, so they still hold for the module at the head.

Guest run 43066328896. Its checkout fetched 602db29e9f2f… into origin/server-admission, and all 6 rows passed (test result: ok. 6 passed). All rows were x86_64. aarch64 is not owed: the branch touches no AArch64 code and none of the shared code beneath it. The rows cover:

  • the normal path of every changed accept call in netstack (requests and link), the supervisor (login, launcher, and power through session_lifecycle's reboot) and the compositor (compositor, and clipboard in the owners table through the vdagent row);
  • diskserver's new driver accept, which runs on nvme in linkcase and netcase, whose images include diskserver, fileserver, usbd and logkeeper.

No row reaches a full table, the line bound or the driver bound. Those rest on the module's host tests and on inspection, which is the right place for them.

After the fixes. B1 and B2 change code unless both are fixed in comments and docs only. The ruling then asks for green rows at a code-identical head, so one dispatch of the same six rows at the new head would meet it. No row reaches the changed behaviour. B3 is a record and needs no run.

Verdict

SEND BACK. Three small blockers remain: B1, the line bound past slots keys; B2, logkeeper's false comment and dropped line; B3, the owed-on-metal record. Round 1's other findings are resolved.

Japabu added a commit that referenced this pull request Nov 11, 2026
… a limit

The table forgot the key it had said of longest ago to remember a new one,
even within that key's limit, so with more keys cycling than it has slots
every refusal was said again, at the clients' combined rate; its own test
asserted that. A table now remembers only the keys it said of within the
last `LIMIT`, at most `slots` of them, forgets none early, and says one line
a limit for all the keys past those. One count of the lines it did not say
rides on the next it does, so it says at most `slots + 1` lines a limit
however many keys arrive and accounts for every one. The count names no key:
one count and a time replace a count per key and the eviction choice.

logkeeper now says every reader it lets go but one that hung up, as the
other servers do. It had discarded a malformed reader's line after the
table counted it as said, which hushed a silent reader's line in the same
limit, and its comment gave a reason the bound had already removed.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 of #1242 at 5d1abda

Architecture

The new representation is simpler than the per-key counts it replaces. The table now keeps a vector of the keys it said of within the last limit, holding at most slots entries, plus two scalars: when the shared line was last said, and the table-wide count of unsaid lines. There is no eviction choice, and no count is stranded on a forgotten key. The bound stays in the module, which owns both the keys and the clock. Size of review r2's change: admission.rs production code goes from 510 to 512 lines and its tests from 346 to 349; logkeeper loses 5 lines, the manual gains 3 and the record gains 8.

Round-2 findings

B1. The bound holds; the accounting claim does not (see R1). By inspection, for any non-decreasing now:

  • Each slot entry lives exactly LIMIT: retain keeps it while now < at + LIMIT. said never holds more than slots entries. So any half-open window of length LIMIT sees at most slots slot lines.
  • The shared line renews only when now >= said_past + LIMIT, so a window sees at most one shared line. The total is at most slots + 1 lines per limit.
  • Keys at the limit boundary. A key or the shared line can be said again exactly LIMIT after its last line. That matches next's now >= deadline, and no half-open window holds both lines.
  • A remembered key's limit passing while the table is saturated. The next say call drops the expired entry, and the next new key takes its slot. The window bound still holds.
  • No key is forgotten early. Nothing removes an entry except retain.
  • Every caller passes a non-decreasing now. I traced the supervisor's handed-over launches (pump_links) and the compositor's copy_begin: both admit after their table's pass has ended.

I checked this with a random-sequence model of say copied from the head: 200,000 sequences, 1 to 4 slots, 1 to 8 keys, time steps of 0 to 21 s that land on the 20 s boundary. In every window of length LIMIT, at most slots + 1 lines were said, and lines carried plus pending equalled events in every sequence. The model also shows one key can be said twice within a limit (S1).

B2. Resolved. inspect.rs:166 says every Left that comes with say: Some, and next never calls say for a hang-up (admission.rs:480). The false comment is gone. This covers every reader the table lets go. An admitted reader whose request is neither MSG_INSPECT nor READ is still closed without a line, as on main; that is outside B2.

B3. Resolved. The narrowed record carries the owed-on-metal bullet (bc262f8).

Findings

R1. BLOCKER: a count that no later line carries is never written, yet three texts say every refusal is accounted for.

  • Locations: sdk/toyos/src/admission.rs:505-509, with left at :449-452 and next at :458-485; the header at :26-28; docs/src/architecture/services.md:37-43; the PR body's "What this does" and its third Risks bullet.
  • What happens: unsaid is read only by the Some that say returns. left and next ignore it, so neither the server's wait nor its pass ever brings a pending count out.
  • An example. A key is refused at 0 s and the line is said. The same key is refused 500 more times before 10 s, all counted. Nothing after that. The log holds one line, and the 500 appear only when some later refusal or let-go is said at that table. On a quiet table, that may be never before the machine stops.
  • Why it matters: the decided property is "no refusal goes unaccounted". The header says "accounts for every one" and the manual "accounts for every refusal", but both hold only once another line follows. This is the case the brief asked about, and it is a cost beyond "the count names no key".
  • Fix, either of:
    • (a) Write the pending count itself once the last line's limit has passed. While unsaid > 0, left also covers the moment LIMIT after the last line said. At that moment every slot entry and the shared line have expired, so next can end the pass with the count as its own event and take the shared line for it. The bound stays at slots + 1, and every count is written within one limit of the line before it. A new Event variant makes each of the 11 Event matches handle it, because none has a wildcard arm. Cost: about 15 lines in the module, one arm per match, and one host test with a negative control. The test refuses one key twice, checks left, then checks next at the last line plus LIMIT for Unsaid(1).
    • (b) Keep the code and state the condition in the header, the manual and Risks: a count since the last line is written with the next line, and with none while no further refusal or let-go comes. This weakens the property as decided, so it needs the decider's explicit acceptance.
  • While these texts are open, the header's "one more line a limit for every key past those" should say "all", as the manual does. The manual's "every refusal" also covers connections let go.

Suggestions

  • S1. admission.rs:494-500: the shared line records no key. So a key said on it can take a freed slot and be said again within the same limit: slots 2; a and b at 0 s; c on the shared line at 1 s; c again at 20.5 s. That is at most two lines per key per limit, and the table bound is unaffected. Two fixes, either of:
    • keep said_past as Option<(K, I)> and include its key in the remembered check;
    • or correct the texts that promise a key at most one line a limit: the PR body's "Write its server's log only once a limit" and the first sentence of issues/a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md, which was written against the per-key representation.
  • S2. The new test checks what it claims within one limit: 3 lines from 100 keys, a remembered key not said again, and a count of 98. But at LIMIT + at(1), both slots and the shared line expire at the same instant, so late is said whichever path frees first. A mutation whose shared line never renews (else if self.said_past.is_none()) passes the whole suite.
    • The cheapest fix is to refuse slots + 2 new keys at LIMIT + at(1) and expect exactly slots + 1 lines. Under that mutation only slots lines are said.
  • S3. system/logkeeper/src/inspect.rs:9: "never the log" now sits beside code that writes a line about the very reader it describes. "Never the log's loop" or deleting the clause would be unambiguous.

For the orchestrator (PR description)

  • "accounts for every one" (What this does) and the third Risks bullet: these follow R1's resolution.
  • "Write its server's log only once a limit": at most twice in one limit unless S1's code change is taken.
  • Guest run 43094768036 at 5d1abda was still queued when this review was posted. Under the existing ruling, landing waits on its six rows at a code-identical head.

Evidence

My runs, in a detached worktree at 5d1abda:

Check Result
cargo test -p toyos --lib admission exit 0; 15 passed
cargo clippy -p toyos --all-targets and -p toyos --lib --features fd-table, with CI's six adopted lints and -D warnings both exit 0
cargo test -p logkeeper exit 0; 27 passed
cargo clippy -p logkeeper --all-targets, adopted lints as warnings exit 0. Three warnings, at main.rs:357, main.rs:611 and policy.rs:78, in files this branch does not touch
random-sequence model of say (scratch, outside the tree) bound and accounting identity hold; one key at most 2 lines per window

The author's negative controls (evidence acc7fe7c, mutations-5d1abdac1) were run at the head, and each restored the file. m5 turns the remembered check off and turns the refusal-bound and malformed-frame tests red. m6 makes the shared line always free and turns the new test red. Both patches are real mutations of the code they name. No control covers R1, because no test asserts that a pending count is written.

If R1 is fixed by (a), it needs the host test and its negative control, a compile of every server (each Event match changes), and a green dispatch of the same six rows at the new head under the ruling. Fixed by (b), it needs no run.

Verdict

SEND BACK. One blocker remains, R1: a count no later line carries is never written, while the header, the manual and the PR say every refusal is accounted for. It needs either the pending count written within a limit or the decider's acceptance of the weaker property, stated where the bound is. B1's bound, B2 and B3 are resolved.

Japabu added a commit that referenced this pull request Nov 11, 2026
…o key twice a limit

A table counted each refusal or let-go connection it did not say into the
next line it said, and wrote the count nowhere else, so on a quiet table a
count could wait for good. Now, while a count is owed, `left` covers the
moment a limit after the table's last line, when every line it said has run
its limit, and `next` ends that pass with `Event::Unsaid` and spends the
shared line on it. The bound stays `slots + 1` lines a limit, and every
refusal and connection let go is said or counted in a line within a limit
of the one before. Each server says the count in its own voice; the module
writes no line itself, since a host build's log discards it and the servers
name their tables their own way.

The shared line remembers its key, so a key said there is not said again in
a slot freed within its limit: no key is said twice in a limit.

The test past the slots refuses `slots + 2` keys in the next limit and
expects `slots + 1` lines, so a shared line that never renews fails it.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r4 of #1242 at a7078c8

Scope: R1, S1 and S2 from review r3, and the code changed for them.

Architecture

Event::Unsaid is the right shape for R1. The module owns the keys and the clock, so it decides when a count falls due. Each server owns its log and its voice. No Event match has a wildcard arm, so the compiler makes every server handle the new variant. The wake needs no new mechanism: left gains one term, and every server already waits on left.

Size of review r3's change:

  • admission.rs: production code from 512 to 547 lines (+35), tests from 349 to 402 (+53).
  • The servers: +17 lines net, one arm per Event match.
  • The manual: +3 lines. The record: +1 line.

Round-3 findings

R1. Resolved. I checked each property by inspection and then with a model.

  • The bound holds. Whenever shared is set, last is set to the same instant, and last never decreases, so shared's time is at most last. next says the count only when now >= last + LIMIT. By then every slot entry and the shared line have run their limit, so the count takes a free shared line. Any window of one limit still holds at most slots slot lines and one shared line.
  • Every count is said within a limit of the line before it. A refusal or let-go is counted only while now < last + LIMIT: at or after that instant every slot is free, so say says it instead of counting it. A pending count is therefore carried by the next say before last + LIMIT, or by the first next at or after it. unsaid > 0 implies last is Some, because counting requires a line said earlier, so next's is_some_and drops nothing.
  • The extra wake cannot spin. left's new term is zero exactly when next's condition holds at the same instant. Every server reads next's clock after left's, so the pass after a zero wait says the count. After that unsaid is 0 and last is now, so the term is None or a whole limit away.
  • The extra wake cannot starve other waits. Each table adds at most one wake per limit, and the term can only shorten a wait.
  • Every server waits on it, for all 11 tables. Each table's left feeds its server's wait, and its next runs to None after every wake:
    • diskserver: both tables;
    • fileserver, logkeeper, usbd and filepicker;
    • netstack: both tables, on Wall, which is std::time::Instant;
    • the supervisor: both tables;
    • the compositor: every drain turn tests left == ZERO for both tables, so it waits at most one frame interval, and take_frames runs both tables' next with a later clock reading.
  • Each new arm writes the count and does nothing else. The continue in the supervisor's and filepicker's arms follows those loops' existing shape.
  • Model. I ran a scratch random-sequence model built from say, the tail of next and left's new term, copied from the head with I = u64 ms.
    • Setup: 300,000 sequences with 1 to 4 slots, 1 to 8 keys, bursts of 1 to 4 events, and steps that land on the 20 s boundary and 1 ms on either side of it. The simulated server wakes exactly at left; for a third of the sequences it wakes up to 3 s late. Each sequence ends on a quiet table that sleeps until left says nothing is owed.
    • Results: 22.9 M events and 1.92 M count lines.
      • No window of one limit held more than slots + 1 lines. 1.9 M windows held exactly slots + 1, so the bound is reached.
      • No key was said twice in a limit.
      • Lines said plus counts carried equalled events in every sequence.
      • With prompt wakes, every line carrying a count came at most a limit after the line before it.
      • No pass that ended in None left left at zero.
      • Every quiet table ended owing nothing.
    • A single burst on a quiet table (50 refusals from 6 keys at 0 s, 2 slots) says 3 lines at 0 s, says Unsaid(47) at exactly LIMIT and not 1 ms before, and then has left return None. In the next limit it says 2 slot lines and counts the rest.
    • The model's checks can fail. Each of three mutations trips its check: a count that takes no shared line (4 lines in a window with 2 slots), a shared line that forgets its key (one key said twice), and no wake for a count (a count never said).
  • The tests catch the defect. a_count_no_line_carries_is_said_on_its_own_a_limit_after_the_last_line and a_count_said_on_its_own_spends_the_shared_line assert the claims. The author's controls m7, m8 and m11 (evidence e7c93eb0, mutations-a7078c89f) mutate exactly what they name, fail the named tests at the head, and restore the file.

S1. Resolved. shared now keeps its key, and that key counts as said until the shared line's limit passes. shared is replaced only once it is free: in say behind shared_free, and in next after last + LIMIT. Control m9 fails a_key_said_on_the_shared_line_is_said_once_a_limit. The record's first sentence is now true.

S2. Resolved. At LIMIT + 1 s the test refuses four new keys and expects [98, 0, 0, None]. Control m10 is review r3's mutation, a shared line that never renews; it fails the test at :908.

S3. Resolved.

Findings

No blockers.

S4. sdk/toyos/src/admission.rs:510-511: "Every line said before now - LIMIT has run its limit" is true at any now. What frees the shared line is that last is the latest line, the shared line included, and the condition puts it at least a limit back. A suggested replacement: "last is the latest line, the shared line's included, so every line has run its limit." Deleting the comment would also do.

S5. sdk/toyos/src/admission.rs:274-277: Unsaid's doc begins "A line its server says of a connection". Event::Unsaid now carries an Unsaid that belongs to no connection. A suggested replacement: "How many of the table's refusals and let-gos went unsaid since its last line; as a say, Some on a line said and None on one counted into the next."

S6. system/compositor/src/session.rs:87: both compositor tables, clients and owners, reach this arm through first_frame. So "compositor: N more connections … since this table's last line went unsaid" does not say which table. The other servers with two tables name theirs: "disk drivers:", "NIC drivers:" and "power:". Separately, Alone prints "1 more connections" for a count of one.

Evidence

My runs, in a detached worktree at a7078c8:

Check Result
cargo test -p toyos --lib admission exit 0; 18 passed
cargo clippy -p toyos --all-targets, and again with --lib --features fd-table; both with CI's six adopted lints and -D warnings both exit 0
cargo test -p logkeeper exit 0; 27 passed
cargo clippy -p logkeeper --all-targets, adopted lints as warnings exit 0. The same three warnings as review r3 (main.rs:357, main.rs:611, policy.rs:78), in files this branch does not touch
the random-sequence model and its three controls (scratch, outside the tree) as above

The author's evidence (e7c93eb0):

  • Host logs. These are stamped "5d1abdac1 + worktree diff e5f05c1bd6b2". git diff 5d1abdac1 a7078c89f | shasum gives the same e5f05c1bd6b2, and the commit adds no files, so they ran on the head's content.
  • Server clippy. cargo clippy of the six host-buildable servers exited 0; every finding is in lines this round did not change.
  • Image build. cargo run -- --build-only at a7078c8 exited 0. It compiles the compositor's and the supervisor's new arms, which no host check builds.

Guest rows:

  • Run 43097664943 at a7078c8 ended in startup_failure at 20:44:46Z with no jobs. Four other dispatches, from two other branches, failed the same way between 20:26Z and 20:44Z, which points to Actions rather than this branch.
  • No row has run at the head, and none had been re-dispatched as of 20:54Z. The PR body already says so.
  • No row reaches the line bound, so the rows cannot test R1 itself. They do cover the servers' loops, whose waits gained the new left term.

For the orchestrator

Under the existing ruling, landing waits on the same six rows passing at a7078c8 or a code-identical head. That condition is not met yet.

Verdict

LAND. R1, S1 and S2 are resolved, and no blocker remains in the code or the texts. The existing ruling's guest condition, above, is still open.

Japabu added a commit that referenced this pull request Nov 11, 2026
…able

The comment where a pass says a count gives the reason the shared line is
free: `last` is the latest line, the shared line's included. `Unsaid`'s doc
no longer ties it to a connection, since `Event::Unsaid` carries one of no
connection's. The compositor's count line names which of its two tables it
is of, as the other servers with two do, and a count of one reads "1 more
connection".

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator verification of 833fd60 (S4–S6 after review r4 LAND): against a7078c8 only a comment, Unsaid's doc, the count line's plural, and the compositor's count line naming its table changed (read with git diff). The six rows are re-dispatched at this head as 43099711330 after tonight's Actions startup failures; #1242 queues when it is green.

Japabu and others added 6 commits November 12, 2026 02:03
Eight servers each kept the connections they had accepted and not yet
served in a table of their own, with their own copy of a 2 s handshake
limit: the compositor, diskserver, filepicker, fileserver, logkeeper,
netstack, the supervisor and usbd. Six of them let a connection go on
that limit before reading what the same wait reported, two waited a
fresh whole limit each pass, six accepted a connection only to close it
when the table was full, and only the file server gave a client a share.

`toyos::admission` is that table once. A connection enters with `admit`
under the key its server reads off its badge and leaves `next` as a
`Client` holding its first whole request, or as the reason it was let
go; a request is read from nothing else. One key holds at most
`Policy::share` of `Policy::slots`; a connection is read before it is
let go on its limit, so a request that is whole is served however late
the pass; the server's wait is what is left of the oldest limit; a full
table takes no more and the port's queue holds the next. The limit is
20 s, Apache httpd's `RequestReadTimeout` header default, where each
copy had 2 s.

Each server supplies its policy and its key and keeps none of the
mechanism:

- fileserver: hellos, keyed by its grant's share, a quarter each as
  before (`bounds::HANDSHAKES`); `bounds::handshake` and its test go,
  and a client is in the served table only once it lent its window or
  was exported, so `Client::waiting` and `since` go.
- netstack: requests keyed by the row and session its connector was
  minted for, a quarter each, refused `ERR_RESOURCE_EXHAUSTED` past it;
  its NIC drivers' hellos through a second table.
- the supervisor: `launcher`, `grants`, `login`, `admin` and `swap` in
  one table keyed by badge, a quarter each, and `power` in a table of
  its own, so a stop never waits behind launches.
- diskserver: clients keyed by their grant, a quarter each, refused
  `Exhausted` past it; a driver is in the driver table only once it has
  said its name, through a table of one connection per driver badge.
  Both drops are now said in its log.
- the compositor: `compositor`'s first frames and a copy's commit in
  one table, and one connection each of `prompt`, `clipboard` and
  `display` in another, which replaces `toyos_desktop::pending_admits`.
- filepicker: half the table each for the picker's callers and the
  supervisor's questions.
- logkeeper and usbd: their `inspect` readers; logkeeper now says a
  drop on the limit, which happens at the table's rate and not a
  reader's.

The per-request path of a served client is unchanged: the file server
still pumps its own `FrameRx` on its own `Connection`, and the table's
frame buffer is inline, as each server's was, so no connection costs an
allocation it did not.

A table whose port carries no badge naming its holder has one share for
all holders (`Policy::unshared`): the compositor, filepicker, logkeeper,
usbd, netstack's `link`, the supervisor's `swap` and `power`.
`issues/one-program-that-fills-a-servers-table-holds-every-other-client-out.md`
says so and what closes it; soundserver's control plane and the surface
hosts, which keep a client past its first request and have no limit, are
there too.

`issues/servers-let-a-client-go-on-the-handshake-limit-with-its-request-unread.md`
is closed. What it asked that this does not do is filed on its own:
`issues/a-client-its-server-let-go-reads-that-as-no-server.md` and
`issues/netstacks-serve-module-has-no-tests.md`.

The manual's services page gains the design of admission.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s are said once a limit

A watch on an acceptor stays armed until it fires, so a server that stopped
watching its acceptor when its table filled still heard the watch it had
armed before, accepted, and closed the connection for the full table: the
supervisor, across its five acceptors and handed-over launches, and
filepicker across its two. `Admission::accept` now takes a connection only
while the table has room and leaves it in the port's queue otherwise, and
every server accepts through it. The compositor parks each acceptor whose
table was full and watches it again once there is room, where it had done so
for `compositor` alone; its `prompt`, `clipboard` and `display` would
otherwise have been watched again at once and fired again.

Every line a server said of a key's refused or let-go connection was one a
connection, at the client's rate, which spends the server's allowance in
logkeeper and counts its own lines that second rather than writing them. The
table now says one line a key a `LIMIT` (`Refused::say`, `Event::Left`'s
`say`) carrying how many went unsaid since, remembers at most `slots` keys,
and never says a hang-up. What a server says of a request it then refuses has
no such bound: `issues/a-client-writes-its-servers-log-at-its-own-rate-through-refused-requests.md`.

diskserver held 16 drivers named plus 16 naming themselves where its comment
says 16 connected at once; the accept now counts both against `MAX_DRIVERS`
(`Admission::held`), and its poller is sized for that bound again.

`Policy::limit` goes: every table's limit is `LIMIT`, as the manual has it,
and `Left::Silent` carries nothing.

The records: the let-go record no longer claims a run since the 20 s limit,
and names which servers refuse for a share; the table record keys `login` by
its row alone, and its exit is the outcome rather than a mechanism the owner
has not chosen. The manual's admission section says both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both benches are down. The `testcases` (with `testcases-watchdog`) and
`shared-debug` boots that #1242's servers were staged for are recorded as
owed on the T14, with what main must show there, in the record the PR
already narrows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a limit

The table forgot the key it had said of longest ago to remember a new one,
even within that key's limit, so with more keys cycling than it has slots
every refusal was said again, at the clients' combined rate; its own test
asserted that. A table now remembers only the keys it said of within the
last `LIMIT`, at most `slots` of them, forgets none early, and says one line
a limit for all the keys past those. One count of the lines it did not say
rides on the next it does, so it says at most `slots + 1` lines a limit
however many keys arrive and accounts for every one. The count names no key:
one count and a time replace a count per key and the eviction choice.

logkeeper now says every reader it lets go but one that hung up, as the
other servers do. It had discarded a malformed reader's line after the
table counted it as said, which hushed a silent reader's line in the same
limit, and its comment gave a reason the bound had already removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o key twice a limit

A table counted each refusal or let-go connection it did not say into the
next line it said, and wrote the count nowhere else, so on a quiet table a
count could wait for good. Now, while a count is owed, `left` covers the
moment a limit after the table's last line, when every line it said has run
its limit, and `next` ends that pass with `Event::Unsaid` and spends the
shared line on it. The bound stays `slots + 1` lines a limit, and every
refusal and connection let go is said or counted in a line within a limit
of the one before. Each server says the count in its own voice; the module
writes no line itself, since a host build's log discards it and the servers
name their tables their own way.

The shared line remembers its key, so a key said there is not said again in
a slot freed within its limit: no key is said twice in a limit.

The test past the slots refuses `slots + 2` keys in the next limit and
expects `slots + 1` lines, so a shared line that never renews fails it.

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

The comment where a pass says a count gives the reason the shared line is
free: `last` is the latest line, the shared line's included. `Unsaid`'s doc
no longer ties it to a connection, since `Event::Unsaid` carries one of no
connection's. The compositor's count line names which of its two tables it
is of, as the other servers with two do, and a count of one reads "1 more
connection".

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

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator verification of the rebase onto 2c8d8bb (d5efd6c): one textual conflict in diskserver's asked (both kept); admission::Client::take_handles now forwards #1239's [ReceivedHandle; N] from FrameRx::take_handles unchanged (read), so the kind check stays the receiver's. Lands on guest run 43125079920 at d5efd6c.

@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Review r4 LAND; S4–S6 and the rebase onto 2c8d8bb verified. Guest run 43125079920 at d5efd6c: success (six rows). Owed on metal recorded (T14 testcases, testcases-watchdog, shared-debug with the refusal-log and driver checks).

@Japabu
Japabu marked this pull request as ready for review November 12, 2026 01:44
@Japabu
Japabu added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 99a015f Nov 12, 2026
11 checks passed
@Japabu
Japabu deleted the server-admission branch November 12, 2026 01:59
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