Repository navigation
A block session never waits on its server's word alone - #1235
Conversation
Review of #1235 at 7c6717aArchitectural assessment
RequiredB1.
B2.
B3.
B4.
Suggestions
Landing
SEND BACK |
…ion's end is an attempt B1. `Session::wait` collected answers between its two pumps and decided after the second. The second pump can answer a caller itself: a write that meets the kept limit once the client's own flush has failed past `MAX_ATTEMPTS` is answered `Device` unsent. With that the outbox and the wire were empty, nothing was owed, no answer had been collected, and the blocking wait's `expect` panicked over an answer it never took. The decision moves into `ClientEnds::exchange`, pure and host-tested: send, receive, send, and only then answer `End(Violation | Silence)`, `Answered` (any answer decided, the last send's included), `Wait(ns)` or `Idle`. `Session::wait` collects after every exchange and acts on its answer. `an_answer_the_last_send_decides_is_never_waited_past` drives the review's trace over a real page; deciding before the last send turns it red. B3. A session's end requeued the flushes and rewritten writes it took uncounted, so a server that ended every session over one of them held it, and every request behind a rewritten write, through endless reconnects. A session's end is now a failed attempt of each flush and rewritten write it took, as a device's refusal is: past `MAX_ATTEMPTS` the flush is answered `Device` and the write is given up. A session that ended with nothing on the wire, a server past the request ring before anything went, ended over the first request waiting, which is treated as if it had been on the wire; without that, such a server held every caller through reconnects too. The blockring model's durability law counted failures one each against `MAX_ATTEMPTS`; with a death now an attempt, its bound of two resets and two deaths gives a flush up, since a reset costs a flush two attempts (the request it fails, and the loss the next flush is told of). The law now weighs failures by the attempts they can cost, and the give-up script is also held at the boundary, a reset and two deaths, which must never give a flush up. B4. One rule in `admits` and the module contract: a server says `Lost` only of a flush and `ReadOnly` only of a caller's write. A rewritten write answered `Lost` is a violation, which B3 bounds. B2. The silence bound's comment states what it is set against: it sits below the NVMe driver's worst-case silence, a controller reset whose steps are each bounded by `CAP.TO`. Its value is left at 60 s for the coordinator's decision. The session module says what an end leads to: the reconnect reaches the same running server, which refuses the open `Held` while it holds a request of the ended session, so the disk is gone and nothing is issued again. The fresh region's reason is the true one: no server's write to an earlier session's page reaches the next, whether or not that server has let the page go. `Session::peak_on_the_wire`, which nothing read, goes with the pump it was measured in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client of the block service could be held for good three ways, each by a server that keeps its connection: - `Session::pump` met the transport's `HeadPastTail` from the request ring's `space`, stopped pushing and left it to "the next drain", which reads only the completion ring. The wait then blocked for `u64::MAX`. - `Client::complete` took a completion's tag off the wire before it judged the status. A read or write answered `Lost` (and a read, a reissue or a flush answered `ReadOnly`) was refused as `Violation::Entry` with its request already gone from the wire, so the session's end answered nobody: the caller's ticket stayed pending across every reconnect, a refused reissue dropped the only copy of an acknowledged write, and a refused pushback flush left writes waiting on it for good. - A server that took a request and answered nothing was waited on with no bound. One mechanism now ends all three. `toyos_blockring::ends::ClientEnds` is the client's two ring ends over the session page, pure and host-tested: `send` and `receive` return the transport's or the client's `Violation` at the call that met it, and `patience` counts `SILENCE_BOUND_NS` (60 s, a policy: several times the 10 s after which the NVMe driver takes a silent command back and answers it itself) from when the client came to be owed a completion or last took one. A look that takes no completion renews nothing, so a server ringing the bell buys no time. `Session::wait` ends the session on a violation or on patience run out exactly as on a hang-up, and `Client::session_ended` answers every request on the wire `Refused`, reissues what was acknowledged and asks waiting flushes again. `Client::complete` judges the status against the request before the tag leaves the wire (`Inflight::answer_admitted`), so a refused completion leaves its request for the session's end. The arms that returned `Violation::Entry` after the take are unreachable and say so. A server ended for breaking the protocol or for silence is still alive: it may hold the region, and its device may still be moving data into the arena. `Session::reconnect` therefore opens over a fresh region, not the one the last server held. That also removes the case `issues/nothing-reopens-a-diskserver-session-over-a-used-page.md` owed a client test for: no client sends a used page any more. `reconnect` no longer pumps; what waits goes out at the next `wait`, where a violation has somewhere to go. The server's side needed nothing: `toyos-blockserve` pulls, never waits on a client, and closes a session on any cursor or entry violation. Tests, all on the host over a real session page with the server's ends driven by the test (`lib/blockring/src/ends.rs`): a server cursor past the request ring, a completion tail past the ring's depth, a second answer for one tag, an answer for a tag never asked, words that are no completion, a write answered `Lost`, an answer never given, a completion renewing patience, and a server that answers but never frees a request entry. Negative controls, each a patch applied, built, run red and reverted: `send` ignoring the request ring's violation reds `a_server_past_the_request_ring_ends_the_session`; the tag taken before the status is judged reds `a_write_answered_lost_is_answered_by_the_sessions_end`, `read_only_answers_a_write_alone` and `a_reissue_refused_read_only_is_kept`; every look renewing patience reds `a_dropped_answer_runs_out_the_servers_patience`. Closes issues/a-block-session-waits-forever-on-a-server-past-its-submission-ring.md, issues/a-read-or-write-answered-lost-is-never-answered.md and issues/nothing-reopens-a-diskserver-session-over-a-used-page.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion's end is an attempt B1. `Session::wait` collected answers between its two pumps and decided after the second. The second pump can answer a caller itself: a write that meets the kept limit once the client's own flush has failed past `MAX_ATTEMPTS` is answered `Device` unsent. With that the outbox and the wire were empty, nothing was owed, no answer had been collected, and the blocking wait's `expect` panicked over an answer it never took. The decision moves into `ClientEnds::exchange`, pure and host-tested: send, receive, send, and only then answer `End(Violation | Silence)`, `Answered` (any answer decided, the last send's included), `Wait(ns)` or `Idle`. `Session::wait` collects after every exchange and acts on its answer. `an_answer_the_last_send_decides_is_never_waited_past` drives the review's trace over a real page; deciding before the last send turns it red. B3. A session's end requeued the flushes and rewritten writes it took uncounted, so a server that ended every session over one of them held it, and every request behind a rewritten write, through endless reconnects. A session's end is now a failed attempt of each flush and rewritten write it took, as a device's refusal is: past `MAX_ATTEMPTS` the flush is answered `Device` and the write is given up. A session that ended with nothing on the wire, a server past the request ring before anything went, ended over the first request waiting, which is treated as if it had been on the wire; without that, such a server held every caller through reconnects too. The blockring model's durability law counted failures one each against `MAX_ATTEMPTS`; with a death now an attempt, its bound of two resets and two deaths gives a flush up, since a reset costs a flush two attempts (the request it fails, and the loss the next flush is told of). The law now weighs failures by the attempts they can cost, and the give-up script is also held at the boundary, a reset and two deaths, which must never give a flush up. B4. One rule in `admits` and the module contract: a server says `Lost` only of a flush and `ReadOnly` only of a caller's write. A rewritten write answered `Lost` is a violation, which B3 bounds. B2. The silence bound's comment states what it is set against: it sits below the NVMe driver's worst-case silence, a controller reset whose steps are each bounded by `CAP.TO`. Its value is left at 60 s for the coordinator's decision. The session module says what an end leads to: the reconnect reaches the same running server, which refuses the open `Held` while it holds a request of the ended session, so the disk is gone and nothing is issued again. The fresh region's reason is the true one: no server's write to an earlier session's page reaches the next, whether or not that server has let the page go. `Session::peak_on_the_wire`, which nothing read, goes with the pump it was measured in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws it keeps the bound Review r1's B2, decided by the coordinator: the client's silence bound is a failure timeout for a hung server, so it sits above the longest a server may legitimately stay silent, and that is the protocol's to state and each driver's to show. `toyos_blockring::SILENCE_BOUND_NS` is now the protocol's contract: while a server owes a session an answer it posts a completion, or ends the session, within it. `ClientEnds` ends a session silent longer, as before; the bound moves from the client's module to the protocol's. The NVMe driver keeps it. A command unanswered for `COMMAND_SILENCE` (10 s) is taken back by a controller reset, which answers nothing until it is done. Its two `CSTS.RDY` transitions stay bounded by `CAP.TO`, at most 255 × 500 ms (NVMe Base 2.0 §3.1.4.1); its nine admin commands (Set Features for the queue count, and a completion and a submission queue for each of four) were bounded by `CAP.TO` too, which the specification gives them no reason to be, and now by the driver's own command policy, `ADMIN_BOUND` = `COMMAND_SILENCE`. `ANSWERED_WITHIN` adds them up, 10 + 2 × 127.5 + 9 × 10 = 355 s, and a compile-time assertion holds it under `SILENCE_BOUND_NS`, which is 355 s: no rounding up. `admin_command` checked the bound and then waited a whole bound for an interrupt, so a wake just before the bound bought a second one and a step could run to twice its bound. `within` waits only for what is left of the bound; `admin_command` and `settle` both wait through it. Tests, on the host: `a_command_left_silent_is_answered_within_355_s` holds the arithmetic; `a_wake_before_the_bound_buys_no_second_bound` wakes one look 1 ms early and holds the whole to the bound, red with the old wait; `an_answer_ends_the_wait`. usbd cannot show the same: its device serves one command at a time from a queue every session on it shares, so a session's request can wait behind others' for minutes with nothing failing. `issues/a-usb-disk-session-can-wait-past-the-block-silence-bound-behind-other-sessions.md` records it, with the NVMe driver's own gap at the edge (its figure counts from a command's submission, not from the request's publication) and its exit. The manual's storage page states the requirement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bound counts APST's On #1131: the reset also sets the APST table, a tenth admin command. The reset's count was written by hand beside the code that issued them, so nothing would have noticed it go stale. `make_io_queues` and `set_power` become `Controller::configure`, which bring-up and the reset both call. It steps through `STEPS`, a const list of the queue count, a completion and a submission queue for each of `MAX_IO_QUEUES`, and the APST table, and issues at most one admin command per step and none besides. `RESET_ADMIN_COMMANDS` is the list's length, so `ANSWERED_WITHIN` follows the code: 10 + 2 × 127.5 + 10 × 10 = 365 s. `toyos_blockring::SILENCE_BOUND_NS` is 365 s, no rounding up. Tests, on the host: `a_command_left_silent_is_answered_within_365_s` and `a_reset_steps_through_the_queues_then_the_power_table`, which holds the list's order. A step added to the list fails the build at the bound's assertion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
941ac6f to
31fda55
Compare
Follow-up review of #1235 at 31fda55Scope. This review covers B1–B4 from the first review and the code changed for them. I read:
Architectural assessment
First review's blockersB1: resolved.
B3: resolved. I checked the rule and the law. The rule.
The empty-wire extension.
The law.
If any weight were too low, the exhaustive search would find a give-up at one of these bounds. The first-try log shows the law had to move. Under the old count, (2,2,0) reached a legitimate give-up in five attempts: two deaths over the flush, then a reset's CI's three blockring mutations still redden the model tests B4: resolved.
B2: resolved in code. One statement of the contract is not (B5).
RequiredB5.
All three are text edits; nothing needs to run again. Suggestions
Evidence and landing
SEND BACK. The only blocker is B5, three text edits. Once B5 is fixed and the guest row is green, this is LAND. |
…lication B5. The manual's storage page said a driver answers every request it holds, and that its worst case is a device's silence and the driver taking a command back. The protocol counts from a request's publication, and the client enforces that: the wait to be taken from the session is inside the bound. The page now says so. `SILENCE_BOUND_NS`'s doc said the bound is set by the slowest server, which the PR's own record refutes: usbd is slower, and the NVMe figure counts from a command's submission. It now says it is set from the NVMe figure, counted from submission, and points at the record for what that leaves out. The usbd record's consequence, a disk gone while the client keeps acknowledged writes no flush covered, is accepted data later reported lost: `severity: [data-loss]`. It also names what the NVMe figure leaves out besides the wait to be pulled: the serve loop's lag before the reset, the reset's own work, posting its answers, and the client's clock starting before it publishes (S1). S2: the model's (1,2,0) bound is failures costing exactly `MAX_ATTEMPTS` attempts, the boundary where the law forbids a give-up, not the most failures of those kinds that never give one up. Text only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator: verified 97c93a6 against follow-up r2's B5. |
Both benches are down. The T14's metalcase boot at 97c93a6 passed, its controller up under the 10 s admin bound; the AMD bench's boot is owed, recorded with what main must show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1236 merged before its owed list reached its branch's record. The osbench_disk A/B of main before and after it, on both benches, goes into the DATA rows' record, which this branch and main both carry, with the readdir.1000 and open_close/stat conditions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator verification, landing under the benches-down rule (metal unavailable; review + host + guest, metal owed):
|
Problem
A client of the block service (diskserver's
Session) could be held for good by a server that keeps its connection:HeadPastTail) stopped the push and was left to "the next drain", which reads only the completion ring; the wait then blocked foru64::MAX.Client::completetook a completion's tag off the wire before judging its status. A read or write answeredLost(or a read, a reissue or a flush answeredReadOnly) was refused with its request already gone, so the session's end answered nobody: the caller's ticket stayed pending across every reconnect, a refused reissue dropped the only copy of an acknowledged write, and a refused pushback flush left writes waiting on it.The contract
A block server is never silent past
toyos_blockring::SILENCE_BOUND_NS(365 s) while it owes a session an answer: from a request's publication, or from its last completion, it posts a completion or ends the session within it. A client that has waited longer ends the session. The bound is the protocol's, stated inlib/blockringand in the manual's storage page; each driver is to show that its worst case from a request's publication, the wait to be taken from the session included, fits. The bound is set from the NVMe driver's figure, which counts from a command's submission to the device; what that leaves out is recorded (below).The NVMe driver shows it at compile time. A command unanswered for
COMMAND_SILENCE(10 s) is taken back by a controller reset, during which the driver answers nothing: twoCSTS.RDYtransitions, each bounded byCAP.TO(at most 255 × 500 ms, NVMe Base 2.0 §3.1.4.1), and the admin commands ofSTEPS, a const list the reset steps through at most one command a step (Set Features for the queue count, a completion and a submission queue for each of four, and since #1131 the APST table), each now bounded by the driver's own command policy,ADMIN_BOUND= 10 s, rather thanCAP.TO. The count is the list's length, so the figure follows the code:ANSWERED_WITHIN= 10 + 2 × 127.5 + 10 × 10 = 365 s, and a compile-time assertion holds it underSILENCE_BOUND_NS, set to exactly that: no rounding up. Bring-up runs the same list (Controller::configure, which replacesmake_io_queuesandset_power).admin_commandused to check its bound and then wait a whole bound more for an interrupt, so one step could run to twice its bound; every wait of the reset now waits only for what is left of its bound (within).usbd cannot show the same yet: its device serves one command at a time from a queue every session on it shares, so one session can wait minutes behind others' commands with nothing failing.
issues/a-usb-disk-session-can-wait-past-the-block-silence-bound-behind-other-sessions.mdrecords it with its exit.Resulting behaviour
toyos_blockring::ends::ClientEndsis the client's two ring ends over the session page and the decision of what the client does next, pure and host-tested.exchangesends what can go, takes every completion posted, sends what those let go, and only then answers:End(Violation): a cursor no server writes, words that are no completion, or a completionClient::completerefuses;End(Silence): owed a completion forSILENCE_BOUND_NS, longer than any server keeping the contract is silent. Looking without taking a completion renews nothing, so a server ringing the bell buys no time;Answered, if any answer is decided, including one the last send decided;Wait(ns)for the server, orIdle.Session::waitcollects every decided answer after the exchange and acts on that answer; ending is exactly the hang-up path. A blocking wait with nothing owed and nothing decided is an invariant violation (its one caller,Served::wait, asserts a request pending) and panics.Client::completejudges the status against the request before the tag leaves the wire (Inflight::answer_admittedinsdk/toyos's transport), by one rule stated in code and the module contract: a server saysLostonly of a flush, andReadOnlyonly of a caller's write. A refused completion leaves its request for the session's end.A session's end is a failed attempt, as a device's refusal is, of each flush and each rewritten write it carried: past
MAX_ATTEMPTSthe flush is answeredDeviceand the write is given up. A session that ended with nothing on the wire (a server past the request ring before anything went) ended over the first request waiting, which is treated as if it had been on the wire: a caller's request is answeredRefused, whichServedcounts againstMAX_RECONNECTS. So no server can hold a caller across reconnects for good.What a session's end leads to:
Servedreconnects through the same name and reaches the same running server. A server that still holds a request of the ended session (a silence end, typically) refuses the openHelduntil that request is answered, so the reconnect fails, the disk is gone for the client's life, and nothing is issued again. A server that holds nothing (it hung up and was restarted, or broke the protocol over a request it had already answered) takes the session back, and the writes the client keeps go out again first.Every session is opened over a region no server has held, so nothing a server writes to an earlier session's page reaches the new one, whether or not that server has let the page go. That also removes the case
issues/nothing-reopens-a-diskserver-session-over-a-used-page.mdowed a client test for.reconnectno longer pumps; held writes go out at the nextwait, where a violation has somewhere to go.The server needed nothing:
toyos-blockservepulls, never waits on a client, and closes a session on any cursor or entry violation.Closes
issues/a-block-session-waits-forever-on-a-server-past-its-submission-ring.md,issues/a-read-or-write-answered-lost-is-never-answered.mdandissues/nothing-reopens-a-diskserver-session-over-a-used-page.md. The kernel record's references to the last are updated.Session::peak_on_the_wire, which nothing read, is gone with the pump it was measured in.Checks
The behaviour is a client's reaction to a misbehaving server, which module tests drive over a real session page with the server's ends held by the test (
lib/blockring/src/ends.rs), and the client's bookkeeping alone (client.rs). No real server produces these inputs, so no guest or metal row can show them.Host, macOS arm64, at 31fda55 (rebased onto main 6d4b7e4 with #1131; the final head):
cargo test -p toyos-blockring -p diskserver -p toyos -p nvme: exit 0. New in the driver:a_command_left_silent_is_answered_within_365_s(the arithmetic),a_reset_steps_through_the_queues_then_the_power_table(the list's order),a_wake_before_the_bound_buys_no_second_bound(a wake 1 ms before the bound, then nothing: the whole takes the bound),an_answer_ends_the_wait.cargo clippy -p toyos-blockring -p toyos --all-targets(gate lints,-D warnings): exit 0; the same overnvmeanddiskserver, which the gate does not lint: exit 0.a_wake_before_the_bound_buys_no_second_bound; the reset's admin commands bounded byCAP.TO's largest value, and one more step inSTEPS, each fail the build at the bound's compile-time assertion.r3/.Host, macOS arm64, at 4eb8b1f (review r1):
cargo test -p toyos-blockring -p diskserver -p toyos: exit 0. New here:an_answer_the_last_send_decides_is_never_waited_past(the review's B1 trace over a real page: the client's own flush failing past its attempts while a caller's write waits behind the limit),a_flush_every_session_ends_over_is_answered_device,a_reissue_every_session_ends_over_is_given_up,a_server_that_ends_every_session_over_one_write_holds_nothing_for_good(a rewritten write answeredLostin every session),a_server_past_the_request_ring_before_anything_went_ends_over_what_waited, anda_reissue_answered_a_word_no_server_says_of_it_is_kept(ReadOnlyandLost).cargo clippy -p toyos-blockring -p toyos --all-targets, the gate's adopted lints,-D warnings: exit 0; the same overdiskserver, which the gate does not lint: exit 0.sendignoring the request ring's violation; m2 the tag taken before the status is judged; m3 every look renewing patience; m4 the decision taken before the last send (B1's order), red atan_answer_the_last_send_decides_is_never_waited_past; m5 a session's end counting nothing, red at the three B3 tests; m6 an end with nothing on the wire taking nothing; m7Lostadmitted of a rewritten write. The CI's three blockring controls still redden the model testsbuild/ci.rsnames. A first m4 run stayed green because the B1 test let its loop run out; the test now requiresAnsweredon the last exchange, and that run is kept in the evidence.--ci hostran at 500507174 before round one (1 of 110 steps red: the build system's macOS llvm environment test,issues/the-llvm-environment-test-breaks-git-on-macos.md). It is not rerun: this round changeslib/blockringandsystem/diskserveronly, whose tests and lint are above.Guest:
nvme_disk_boots(QEMU x86-64) boots off one NVMe disk with every role served through the block service's sessions, so every request runs throughSession::waitand the driver's bring-up runs its admin commands under the new bound. It passed at 7c6717a (run 43049658371). The landing run, https://github.com/ToyOSOrg/ToyOS/actions/runs/43069269031 at 31fda55, passed:PASS nvme_disk_boots, 1 of 1, all five steps green (2026-11-11 20:01Z); run 43062272826 at 4eb8b1f was cancelled unrun, superseded. The head, 19c3b5f, differs from 31fda55 in text alone: the manual, three records, and comments inlib.rs,model.rsandnvme.rs(git diff -U0 31fda554e 19c3b5f3a -- '*.rs'changes no line that is not a comment), so the run stands for it.Metal: both benches are down, and metal is no longer a landing condition. The T14 booted
metalcase(rowsmicrocodeandmachine_reboot) at 97c93a6, code-identical to the head, and passed: the nvme driver brought its controller up under the 10 s admin bound (up:2 ms after it started, APST on, no admin command unanswered), DATA was served off the NVMe, and no session ended. Owed on metal (recorded inissues/the-nvme-driver-resets-a-drive-that-is-slow-and-healthy.md): the AMD bench, the same two rows in one boot of main after this lands, which must pass with the nvme driver'sup:line, noNOT SERVINGorwas not answered in, DATA served off the NVMe, and no session ended by a violation, silence or a refused reconnect. This branch also carries #1236's owed list, which reached no branch before #1236 merged: both benches,osbench_disk'sfs.disk.*rows in one A/B of main before and after #1236; on the AMD bench main after must showreaddir.1000below main before on every boot, and on both no other row may resolve against main after, which is whereopen_closeandstat(1.8 and 8 % against #1236's head on the T14) recur or not. It is recorded inissues/smaller-data-rows-trail-main-by-up-to-half.md, the DATA rows' record both this branch and main carry. No guest row restarts the block service, soSession::reconnectruns on no guest;diskserver's host tests coverServed's reconnect decisions over a scripted link.Evidence: https://github.com/ToyOSOrg/evidence/tree/55f29b4c0aa4fe30901a691e5d89d6831d4f4de2 (round one at the top, review r1 under
r2/, B2 underr3/, the rebase onto #1131 underr4/).Risks
severity: [data-loss]): a stick serving several busy sessions can have one ended by its client, whose disk is then gone for its life, and acknowledged writes no flush covered are reported lost. The NVMe driver's figure equals the bound with no slack and counts from a command's submission; the same record names what it leaves out: the wait to be pulled (behind other sessions' requests while the device is full, or behind a reset), the serve loop's lag before a reset, the reset's own work, posting its answers, and the client's clock starting before it publishes.CAP.TO; a controller slower than that to answer Identify is refused rather than served.Session::reconnectover a fresh region runs on no guest (no row restarts the block service);diskserver's host tests coverServed's reconnect decisions over a scripted link.🤖 Generated with Claude Code