Advance the mail cursor to the highest post handed over (#288) - #295
Conversation
ac19002 to
f47b4f3
Compare
6fa9f30 to
1610866
Compare
ddddbd6 to
72540c4
Compare
mail inbox read the room in one step and moved the cursor in another, and the daemon moved it to the room's latest post — so a post landing between the two was marked read without ever being printed, a race the CLI called accepted. MailboxQuery.advanceCursor now moves the reader's cursor to Mailbox.highestDeliveredID inside GraphStore.mailbox, in the same actor turn the answer is drawn; persisted, never broadcast, refused to the asker alone. highestDeliveredID is the highest id below which every unread post was handed over — nil for an empty page or a filtered first post — and the daemon refuses advanceCursor with search. An unread answer is a page that is never smaller than the room (inboxPageSize = maxNotices + maxLetters, pinned by test) so pruning cannot eat a page between requests, and what pruning does eat is counted on the answer (prunedUnread) and said out loud. The legacy GraphCommand.mailroomInbox moves no cursor and answers with an error naming the version skew, so an older CLI that received nothing advances nothing. --mark walks the unread mail through the mailbox. The Swift CLI and the remote shim send one request, and the accepted-race comment is gone from both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
72540c4 to
7d8695c
Compare
Independent review — final verdict at
|
| Claim | How I attacked it | Result |
|---|---|---|
search + advanceCursor cannot eat unread mail |
Encoded the pair through JSONEncoder/JSONDecoder — it survives the wire intact — then handed the decoded query to GraphStore.mailbox |
✅ MailboxRefusal, cursor still nil, all ten posts still unread. The guard is genuinely daemon-side, ahead of the room-off and identity guards; no caller convention is load-bearing |
highestDeliveredID is honest in every answer shape |
Empty answer; caught up; cursor past the end; a search whose first hit is not the first unread post; the whole room in one answer; a room pruned under a reader whose cursor sits below what survived | ✅ nil wherever nothing was handed over; after a prune it names the surviving high-water mark and counts the gap in prunedUnread |
| The legacy refusal reaches the asker, not everyone | Two connections registered on one store, one sends .mailroomInbox, the bystander reads with a blocking 1 s deadline (a MSG_DONTWAIT read here races OutboundChannels and gives a false pass — my own first probe did exactly that) |
✅ bystander gets nothing. This was a real defect at 72540c4 and refuseLegacyInbox(to:) fixes it correctly |
| A page can never strand a backlog | Served a room at its retention cap to a reader at cursor 0 | ✅ remaining == 0, whole surviving room in one answer, cursor at its last id |
| The cursor write is persisted, not broadcast, and clients still converge | Your MSG_PEEK test, plus reading the wiring: ProjectRegistry's onGraphChanged is saveGraph + refreshAwakeAssertion only; broadcast is notifyClients, untouched |
✅ and convergence holds. renderStatusMailroomLine is the only reader of lastMailroomRead, and every graphcode status is a fresh connection served from the daemon's authoritative graph. The app rail uses per-window seenMailroomPostID, not the node cursor. Nothing on the wire lets a client write a whole graph back, so a stale in-app copy cannot clobber the cursor |
| Shim parity | Read cliShimSource against the Swift renderers line by line; extracted the shim as delivered and parsed it |
✅ both new footers and both --json keys are byte-identical, and --mark's walk mirrors the Swift repeat/while |
Gate in my worktree at 7d8695c: 113 of 114 pass across MailboxTests, MailroomTests, MailroomCommandTests, MailroomFetchTests, MailroomBudgetTests, MailroomLegacyNamesTests, RemoteCLIShimTests, BoardsOffRegressionTests and my own suite — the one failure is my probe for finding 2. -scheme graphcode-cli and -scheme graphcoded both ** BUILD SUCCEEDED **. swiftlint lint: 0 errors. swift format lint --recursive --strict over GraphcodeKit graphcode graphcode-cli graphcoded MailroomKit: clean.
🔴 Finding 1 — 7d8695c deletes the only test of the fix this round exists for
$ diff <(git show 72540c4:…/MailboxTests.swift | grep -oE 'func [a-zA-Z]+\(' | sort) \
<(git show 7d8695c:…/MailboxTests.swift | grep -oE 'func [a-zA-Z]+\(' | sort)
< func aSearchedInboxIsRefusedTheAdvance(
< func theLegacyInboxCommandMovesNoCursor(
Two removed, none added. After that:
search+advanceCursorhas no test anywhere in the repo.grep -rn "MailboxRefusal" graphcode/Testsfinds onlytheAdvanceIsRefusedWhereTheOldCursorCommandWas, which covers room-off and unknown-reader — not the search pair. The one defect this round was opened to fix now ships with zero regression cover.- The legacy refusal has no test at all.
grep -rn "predates the daemon" --include='*.swift' .matchesGraphStore.swift:1758and nothing else. Neither the message nor the per-connection routing ofrefuseLegacyInbox(to:)is asserted anywhere. (I was told aMSG_PEEKbystander test had landed with it; it has not — that is why I checked rather than relaying it.) The only surviving half isMailroomTests.syncAdvancesCursorAndNeverMovesItBackward, which asserts the cursor does not move.
Both deletions look collateral: advancingTheCursorStopsAtWhatWasHandedOver had to be rewritten (correctly) because inboxPageSize = maxNotices + maxLetters made its 405-post fixture unconstructible, and the two neighbours went with it. Nothing about the behaviour regressed — my probes prove that. But this PR's own history is a landmine that only a reviewer caught, and the fix for it is now untested. That is how it comes back.
Fix: restore the two tests, or cherry-pick MailboxCursorReviewTests.aSearchedInboxIsRefusedAndMovesNothing and theLegacyRefusalMustNotBeBroadcastToBystanders from review/295-audit — the second is worth having on its own, since nothing else pins "to the asker, not the room". Note the blocking-read helper in it: a MSG_DONTWAIT read straight after handle passes whether or not the bug is there.
🟡 Finding 2 — prunedUnread is silent for a reader that has never read
if let lastRead {
let latest = posts.map(\.id).max() ?? lastRead
pruned = max(0, latest - lastRead - selected.count)
}A loop created by graphcode node create has lastMailroomRead == nil and keeps it until its first mail inbox. The branch never runs, so mail that landed during its life and was pruned before it read is reported as nothing lost — the exact silence prunedUnread exists to break.
Failing probe (aReaderThatNeverReadIsStillToldWhatItLost): loop created, 260 posts land, retention prunes 60, the loop runs its first inbox.
✘ Expectation failed: (answer.prunedUnread → 0) == 60
"60 posts landed while this loop existed and were pruned before it read; it was told 0"
Ordinary path: a loop created, working for a few hours, reading the room late. The docstring says "Zero for a reader the room does not know" — but this reader is one the room knows; it has simply not read yet, and nil cannot tell those two apart.
The clean fix is upstream of serve: stamp lastMailroomRead with the room's latest id when a node is created. A live loop then never carries nil, "unread" always means "since you existed", and a new loop's first inbox stops being the entire retained backlog of a graph it was not part of. Not a merge blocker — an issue.
On the paging path now being unreachable
Asked for explicitly, so plainly: pruned() caps the room at maxNotices + maxLetters = 400 and inboxPageSize is now exactly that, so selected.count > inboxPageSize is structurally impossible. remaining is always 0, the footer can never print, and --mark's loop always exits after one pass.
Keep it — but do two things, because "unreachable" and "untested" are not the same problem and only one of them is forced:
- The paging path is not untestable, only untestable through the store.
Mailroom.servetakespostsas a parameter, so handing it 500 synthetic posts exercises the slice,remaining,highestDeliveredID's page edge, the CLI footer and the shim footer without a room that can hold them.aPagedBacklogRendersByteEqualTooalready does exactly this and is the reason the footer strings are still covered. Add theserve-level page test back the same way and nothing here is dead-in-the-sense-that-matters — dead render code that nobody exercises is how a wrong string reaches a user two years later. - Pin the invariant as an invariant.
#expect(Mailroom.inboxPageSize >= Mailroom.maxNotices + Mailroom.maxLetters)with the reason in the comment, so lowering the page is a red test rather than a silently reopened pruning window. You have this; keep it next to the constant.
Removing the mechanism instead would make page >= room load-bearing with nothing left to catch a later change, which is the worse trade — the field costs one Int on the wire and the code costs four lines. Keeping it and saying so in the comment (structurally zero today; exists so that lowering the page is survivable rather than silent) is the right call.
While you are there: --mark's walk has no iteration bound (repeat { fetch } while remaining > 0, and while True in the shim). Its condition is now structurally always-false, which makes an unbounded loop against a socket whose peer version you do not control pure downside. for _ in 0..<8 costs nothing.
Nits
Mailbox.highestDeliveredID's docstring is only true for.unread. It promises "the highest id below which every unread post was handed over", but.boardand.poststill returnselected.last?.id— andtheCursorCeilingNeverPassesMailThatWasNotHandedOverpinsserve(graph, .board, search: "release").highestDeliveredID == 3, the highest matching post. Harmless today becauseGraphStoreignoresadvanceCursorfor those selections, but the field's own doc is what the next caller reads, and the next caller may be the shim. One clause fixes it.refuseLegacyInboxdrops the refusal silently if the asker is not in this store'sconnections.if let connectionID, connections[connectionID] != nil— a.graphCommandfor a project this connection never opened still routes to the store (the registry guards onstores[canonicalPath], not on membership), and the old CLI'swaitForEvent { .graphChanged, .errorOccurred }would then hang rather than fail. Not reachable through the real client, which opens first — but the registry already has thefileDescriptorand could answer without the store, which removes the case entirely.--markships the whole backlog only to discard it: up to 400 headlined posts across the wire for a command that prints one line, in a PR about wire size. Aposts: falseon the query, or a.markReadselection, would make it one small round trip.- The shim hard-codes
MAX_NOTICES = 200/MAX_LETTERS = 200where Swift readsMailroom.maxNotices/maxLetters.aPagedBacklogRendersByteEqualToowould catch drift, so this is a note — but a delivered shim is the one copy nobody rebuilds by hand. - Behaviour change worth a line in the PR body: with the room off,
mail inboxused to print the posts and then fail; the refusal now precedes the answer, so it fails with nothing printed. Reading was previously ungated on purpose ("a room switched off still shows what was said while it was on") and for the inbox it now effectively is not.
For the record, on earlier SHAs
Two things I found that are already fixed, noted only because they bear on how much the gate can be trusted:
ddddbd6did not compile.MailroomCommandTests.swift:418: value of optional type 'Bool?' not unwrapped— I hit it on my own first run there. No gate can have been green at that SHA.72540c4fixed it, and the fix flipped the assertion fromprunedUnread == nilto== 3, because the old spelling was#expect(try? decoder.decode(…).prunedUnread == nil)— an#expectover anOptional<Bool>, which cannot fail. Worth grepping for that shape elsewhere in the suite.- At
6fa9f30all three of my opening probes failed:search+advanceCursormoved the cursor to#10and lost nine posts;mailroomInboxmoved a cursor to#140having delivered nothing; andinboxPageSize = 100against a 200 + 200 retention cap let 100 posts be pruned between page one and page two. All three are properly closed now, and the third was closed by tying the page to retention rather than by making the page bigger, which is the right shape of fix.
Summary
The cursor rule this arrives at is correct, and the fixes for the previous review's landmine survived everything I could throw at them — the refusal is in the daemon, not in a caller's manners, and highestDeliveredID is honest in every shape I could build, including one the PR does not test. Ship it. But put back the two tests that went missing on the way here: the fix for a bug a reviewer had to find should not merge with nothing watching it.
Encode a broadcast once, not once per connection (#288) `send` encoded the event inside the per-connection loop, so one graph change cost C full encodes of the same snapshot with C clients attached. The encode is hoisted into `notifyClients`, which produces one `EncodedEvent` — data plus its superseding key — and hands the same frame to every connection. The review caught a regression the hoist introduced: `send` used to look the connection up *before* encoding, so a clientless store did zero encodes, and hoisting made it unconditional. The daemon deliberately runs with no clients attached, so it had started paying a 63 KB snapshot encode per change that went nowhere — in the one dimension this change exists to improve. `notifyClients` now returns early when nothing is attached. Reviewed independently and approved; the four invariants from #291 were checked to survive, and `deliver` keeps the drop-a-dead-connection-on-false rule callers depend on. Gated on the actual merged result rather than the branch alone, since #295 landed on the same code in between: 1631 tests / 168 suites / 0 failures, no restarts, on main + this branch merged locally. Partially addresses #288.
Restore the cursor tests #295 deleted #295 removed the two tests covering the rules it exists to enforce and added nothing in their place, so `search` combined with `advanceCursor` had no test anywhere in the repo and the string naming the version skew appeared in none. The behaviour was right — the review proved it by probe — but the fix for a bug only a reviewer found was sitting unwatched on main. These are the review's own probes and go further than the pair that was deleted: the decoded query is driven through `GraphStore.mailbox` after a Codable round trip rather than trusting the CLI never to send the pair; `highestDeliveredID` is checked in every answer shape including a room pruned under a reader whose cursor sits below what survived; the page is exercised through `Mailroom.serve` with more posts than a room can hold, which is the only way to reach that path now a page is the room's own cap; and the legacy refusal is asserted to reach the asker while a bystander's socket stays empty. That last one needs a blocking read with a deadline. `MSG_DONTWAIT` straight after `handle` races the outbound channel and reads empty — indistinguishable from a broadcast that never happened, and it gave a false pass on a build where the bug was still live. A fifth probe is deliberately not here: it fails on main because `prunedUnread` reports 0 for a reader that never read. That is a real defect needing a product decision, filed as #300. Authored by the review on `review/295-audit`, cherry-picked unchanged. Verified passing on cf90bb6; Linux CI green.
Based on
main(#293 merged). Time-sensitive:mainnow drops posts from the snapshot while the legacy-cursor fix below is only here, so no beta until this lands.The defect
mail inboxread the room in one step and moved the cursor in another, and the daemon moved it to the room's latest post. A post landing between the two was marked read without ever being printed. The CLI said so in a comment — "Known race, accepted" — and the remote shim echoed it. #288 asks for the cursor to advance only through the highest post actually returned to the reader.The change
MailboxQuery.advanceCursorasks the daemon to move the reader's cursor toMailbox.highestDeliveredID— the last post in the answer — insideGraphStore.mailbox, in the same turn the answer is drawn. Nothing can land in between. The write is persisted and not broadcast: a cursor is the reader's alone, and the next snapshot anything else causes carries it anyway. A refusal (unknown reader, room off) comes back as an error to the asking connection only, rather than throughannounceError, which broadcasts to every client..unreadanswer is a page —Mailroom.inboxPageSize— that says what it left (remaining); the cursor stops at the page. The page size is pinned tomaxNotices + maxLettersby an invariant test: a page smaller than retention opened a window the unbounded answer never had (page two of a backlog pruned before the reader asked, skipped in silence), so a live room has no page two by construction.mail listandmail readare never paged.Mailbox.prunedUnreadis the number of posts that landed after a reader's cursor and were pruned before it asked — ids are contiguous, so it is exact — and the CLI and shim say it out loud, with or without posts to show ("N posts landed since your last inbox and were pruned before you read them — the room keeps 200 notices and 200 letters; read it more often"). That loss predates this series and was silent.mail inboxsends one frame (mailboxwithadvanceCursor) and prints what it got, plus a footer when there is more.--mark— everything read, unseen — is the one spelling that still usesmailroomInbox, now documented as exactly that.--jsongainsremaining, present only when the answer is a page.From the #293 review (both land here, as the review asked)
mailroomInbox, which moved its cursor to the room's latest — 182 posts unreachable, permanently.GraphCommand.mailroomInboxnow moves no cursor at all: the daemon answers it with an error naming the skew ("this graphcode CLI predates the daemon's mailbox — nothing was marked read. Upgrade graphcode…"), so a client that received nothing advances nothing and the failure is loud and recoverable. The new CLI never sends it;--markwalks the unread mail through the mailbox page by page, headlines only, and reports the last id it passed.highestDeliveredIDundersearch(and every other shape). It named the last matching post while unread posts below it were filtered out. It is now the highest id below which every unread post was handed over —nilfor an empty page,nilwhen the first unread post was filtered out, exactly the matched prefix otherwise, and a page's edge only when nothing below it was skipped. Belt and braces: the daemon refusesadvanceCursortogether withsearch(MailboxRefusal, to the asker alone), since the protocol is reachable by clients we do not ship. Tests cover every answer shape, the reviewer's case verbatim, the refusal, and that the legacy command leaves the cursor untouched.Verification
Gate: full Xcode suite (1622 tests in 167 suites, 0 failures, on the stack rebased onto main post-#291), swiftlint 0 errors, swift-format clean,
graphcodedandgraphcode-clischemes build; SwiftPMswift buildand.build/debug/graphcodepass locally.Tests added: the page and its
remaining; an older answer decoding with nothing remaining; the cursor stopping at the page and the next request bringing the rest, persisted once per advance and never broadcast (aMSG_PEEKon the connection finds nothing); refusals leaving the cursor untouched and never touching a plain read; the registry returning a refused advance to the asker; the CLI footer and--jsonshape; and the shim sending one request withadvanceCursorand rendering a paged backlog byte-equal to Swift.Not in this PR
Coalescing presence-poll broadcasts (fix 5), #289's diagnostics (fix 6), and encoding once per broadcast (fix 3, after #291).
Refs #288
🤖 Generated with Claude Code
https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N