Never block the daemon actor on a client write (#288) - #291
Conversation
A `graphChanged` frame carries the whole graph — 176 KB on the graph this was measured against — while a unix socket's `SO_SNDBUF` is 8 KB. A frame that size cannot be handed to the kernel and forgotten: the blocking `write` only completes as the peer drains it, repeatedly, mid-frame. Both callers doing that writing are actors. So one client that stopped reading for a moment — which is exactly what the `graphcode` CLI does while it renders its output, holding its connection open the whole time — parked a `GraphStore` thread inside `write(2)`, and every other command for that project queued behind it until that client happened to exit. Measured against the live daemon: a 6 ms round trip became a hard timeout past 12 s with one non-draining client attached and nothing else wrong. The CLI's own receive budget is 10 s, so it surfaced as issue #288 — `mail inbox` timing out against a daemon that was never busy, only blocked. It freed the instant that client exited, which is why it read as intermittent. Frames now go to a per-connection `OutboundChannel` with a writer thread of its own, so the actor hands a frame over and returns. An undelivered `graphChanged` is superseded by a newer one rather than queued behind it: the event carries the whole graph and never a diff, so a snapshot that has not left the building has nothing left to say. Past a backlog budget the client is dropped and told so on stderr — a disconnect the daemon chooses otherwise reads afterwards like one it suffered. The channel also takes ownership of the descriptor. `close` stops the writer and waits for it before closing, because freeing the number while a write is in flight would hand it to the next `accept` with a reader still blocked on it. This is the defect behind #288 rather than its Mailroom half: the payload is 75% mail bodies, but every command broadcasts a full graph, so any of them could wedge the daemon this way. Bounding the mail response is worth doing and does not fix it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
Three defects the test suite found, all in the same seam. `OutboundChannels.open` was called only from the daemon's accept loop, so any other path that registered a connection got a descriptor with no channel and its frames were silently dropped. That is invisible at the sending end and shows up at the other as a client waiting forever — it hung the suite on a registry test that registers a connection directly. Opening now happens in `ProjectRegistry.addConnection`, the one place every caller goes through, and `send` gives an unknown descriptor a channel rather than losing the frame. Writing to a socket whose peer has gone raises `SIGPIPE`, and the default action kills the process. `graphcoded` arms the sockets it accepts, so this survived in production and crashed the test host instead. A writer that only survives because its caller remembered is a crash waiting for the one caller that does not, so the channel arms the descriptor itself. The writer thread ran at the default QoS while `closeAndWait` blocks its caller on it — a priority inversion, which Xcode's Thread Performance Checker flagged. It now matches the interactive work it serves. Also: a stale channel for a reused descriptor is detached rather than closed. The number belongs to the new connection by then, and `closeAndWait`'s `shutdown` would have torn that one down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
`GraphStore.addConnection` bound to whatever channel the descriptor already had. Descriptor numbers are recycled aggressively, so a channel left dead by a failed write outlived its connection and was inherited by the next one given that number — which then had every send refused and was dropped as disconnected on the very snapshot it had joined for. Eighteen presence and summary-board assertions failed that way: with the connection gone the poll had nobody to tell, so readings never landed on the graph. `open` now means "ensure a live channel" and both registration points call it. It keeps an existing live channel rather than replacing it, because a connection is registered more than once on its way in — the registry records it, then each store records it again as the client joins — and replacing would discard whatever the first call had already queued. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
Review — independent verification of #291 (head
|
| Claim | Result | How |
|---|---|---|
| Gate 1597 tests / 164 suites / 0 failures | ✅ confirmed on 5adb642 |
full xcodebuild test from my own worktree |
| swiftlint 0 errors, swift-format clean | ✅ | ran both |
graphcoded + graphcode-cli schemes build |
✅ | built both |
| Stall reproduction (deaf client wedges every other command) | ✅ 12.0 s timeout before → 0.018 s / 0.105 s after (two runs) | my own raw-socket script against a fresh isolated daemon seeded to a 200,979-byte frame; "before" = installed 0.1.63 |
| Linux CI | ❌ red — OutboundChannel.swift:182:30: cannot convert value of type 'Int' to expected argument type 'Int32' (Glibc's SHUT_RDWR is Int) |
gh pr checks 291 |
Findings
| # | Severity | Finding | Evidence |
|---|---|---|---|
| 1 | 🔴 regression, blocks merge | Cross-project supersession. GraphStore.send keys every snapshot with the constant "graphChanged", but one connection is joined to many stores (ProjectRegistry.joinSidebars adds every sidebar to every open project on the same fd; the app also openProjects each restored project over its one connection). While the writer is parked on a frame, project B's snapshot deletes project A's undelivered snapshot. A's client stays stale until A changes again — which may be never. Pre-PR the blocking write delivered every snapshot, so this is new. |
Failing test snapshotsFromDifferentProjectsMustNotSupersedeEachOther: delivered → ["/tmp/review/b"]. Also reproduced end-to-end on the PR daemon: a client joined to A and B, C creates node NEEDED in A then mutates B → the client received A's earlier snapshot and B's, and NEEDED never arrived. |
| 2 | 🟠 fix before merge | OutboundChannels.send opens a channel on an unregistered descriptor. After close(fd) the kernel recycles the number; a store still holding it (it only forgets a connection on its next refused send, and see the pre-existing race below) sends → a channel is created on whatever socket owns that number now → open() from the new connection then keeps it as alive. The new client receives a frame meant for a connection that no longer exists. The registry has exactly the information needed to refuse this and doesn't use it. |
Failing test aStaleSendMustNotBindAChannelToTheNextOwnerOfTheDescriptor: accepted → true, and the new peer's first frame is the stale one. With lazy creation removed the full suite is still green (1600/165/0) — so open() at the two registration sites already covers the "silent frame loss" that motivated it. |
| 3 | 🟡 sharp edge | The valve counts the frame it just appended, so one frame > 4 MB trips it on a queue of one; every client would be dropped on the broadcast it joined for. Not reachable today (50 nodes, 1 KB post bodies, 200+200 posts ≈ well under 1 MB), but the failure mode is total and silent to clients. | Failing test oneFrameLargerThanTheBudgetIsNotABacklog: .connectionClosed. |
| 4 | 🟡 pre-existing, not made worse — but the PR's ownership claim doesn't cover it | ProjectRegistry.removeConnection is reentrant. It iterates a copy of connectionProjectPaths[id] with an await per store. While suspended, another connection's openProject of a new path runs joinSidebars, which inserts the departing sidebar into the new store (connectionProjectPaths[X].insert(B) + storeB.addConnection(X, fdX)). The loop never sees B, close(fdX) runs, and storeB holds X → fdX forever: every later B broadcast goes to whichever connection is handed that number — including a one-shot CLI, which prints the first graphChanged it sees as its own project. Needs the app disconnecting at the same instant a CLI opens a new folder, so rare. |
Reasoning from ProjectRegistry.swift:128-137 and :390-397; I did not manage a deterministic failing test for the interleaving. Real fix: key the registry by connection UUID (every caller has it) rather than by fd, and re-read the path set after each await in removeConnection. |
| 5 | ⚪ nit | OutboundChannels.close calls posixClose(fd) even when it had no channel for that number — a second call for a number would close someone else's descriptor. Only one caller today; worth a guard or a comment. Also: BoardsOffRegressionTests hands stores raw /dev/null fds and closes them itself, leaving live channels in the global registry keyed by numbers later tests reuse — harmless today because channels are number-bound, but it is the same mechanism as #2. |
reading |
What I could not break
| Seam | Result |
|---|---|
Close path (closeAndWait → shutdown → wait → close(2)) |
No ordering defect found. shutdown(SHUT_RDWR) is issued before the wait in every path that sets isClosing; a detached channel is only ever one whose shutdown already went out, so no channel shuts a number it no longer owns; lock order is registry → channel only. 150 rounds of 4-lane concurrent sends racing close with the freed number recycled straight into the next pair: no hang, no crash, 1.4 s (concurrentSendsAndClosesWithRecycledDescriptorsNeverHang, passes on 5adb642 too). |
errorOccurred being dropped |
Cannot happen by supersession (unkeyed frames are never removed); only the valve or close drops them, both of which end the connection. Ordering across keys is preserved. |
| 4 MB valve | Defensible once #3 is fixed and the key is per project: a sidebar's backlog is then bounded by open projects × snapshot (~23 at today's 176 KB; ~90 once the mailroom leaves graphChanged). Reaching it genuinely means a stopped reader. |
Branch with the tests and candidate fixes
review/291-probes — two commits on top of 5adb642, so the tests can be cherry-picked alone:
9d0f2caAdd review probes for the outbound channel—graphcode/Tests/OutboundChannelReviewTests.swift(4 tests; the first three fail on5adb642).909acecCandidate fixes for the review probes— per-project key inGraphStore.send,sendreturnsfalsefor an unregistered fd, valve measurespendingBytes - data.count. Full gate with both commits: 1600 tests / 165 suites / 0 failures.
Still needed from the author: the Glibc SHUT_RDWR cast for Linux, and I'd drop Closes #288 to a reference — the mailroom-out-of-graphChanged half is real and separate.
All four are mine, and two of them are the parts of the design I had reasoned about and got wrong. Cross-project supersession. The superseding key was the bare string "graphChanged", but one connection joins as many projects as it likes and every project's store writes to that one socket. The newest snapshot therefore displaced a *different* project's undelivered one, so a client that had just joined two projects silently never received the first — its loops simply never appeared. Keyed per graph now. Reproduced by both reviews, one with an end-to-end run. The Linux build. Glibc imports SHUT_RDWR as Int where Darwin gives Int32, so the shutdown call compiled on macOS and failed CI. I called this PR green on the local gate alone without looking at the Linux job, which was already red. Lazy channel creation, removed. It was added as insurance against losing a frame and was the opposite: descriptor numbers are recycled, so a send arriving after its connection closed minted a channel on a number the kernel had already reassigned and handed the departed connection's frame to whoever held it now. A review proved that with a failing test after I had talked myself into the case being unreachable. Registration is what makes a descriptor writable; both addConnection paths open a channel, so an unregistered descriptor is refused — which is also the signal a broadcaster needs to forget a connection that has gone. The backlog valve tripping on one frame. It measured the whole queue including the frame just added, so a single oversized snapshot tripped it alone and disconnected a healthy reader for opening a big graph — the bigger the graph, the more certain that nobody could open it. The budget is for a backlog, so the newest frame is excluded and a single frame always passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
Two independent reviews checked the previous commit. This folds in what they found, plus a defect neither of them hit. The one they missed: the full suite hung, twice, on a channel whose client never read — passing in isolation every time. A thread already parked inside a blocking `write(2)` on a unix socket is not reliably woken by another thread's `shutdown`, so `closeAndWait` could wait for ever. That is the original bug moved one layer down, from the actor to the connection loop, in the exact seam the previous commit claimed to have made safe. Writes are now non-blocking per call (`MSG_DONTWAIT`) with a bounded `poll` for writability, so the writer notices a close by itself rather than depending on being interrupted. `MSG_DONTWAIT` per call and not `O_NONBLOCK` on the descriptor: that flag lives on the open file description, which the daemon's reader shares, and a reader returning `EAGAIN` would tear down every connection. `send(2)` only works on sockets, and tests hand these stores a `/dev/null`, so the channel now picks its write call by what the descriptor actually is. A non-socket cannot block a writer the way a stopped reader can, so a plain `write` is correct there. `OutboundChannels.close` no longer closes a descriptor it holds no channel for — by the second call that number belongs to somebody else. The review's probes replace three of my own tests. They are better: they drive the real `GraphStore` path rather than raw superseding keys, and force genuine descriptor reuse with `dup2` instead of assuming it. The 150-round close/recycle stress test is what a change in this seam should have to pass. Gate: 1601 tests / 165 suites / 0 failures, twice, with no restarts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
|
All five findings addressed, plus one your probes did not reach. Thank you for the branch — I took
One correction to your review
The stress test passes, but the seam is not sound. The full suite hung twice on Writes are now non-blocking per call ( Your 150 rounds did not catch it because every round's peer is drained or closed promptly; the hang needs a peer that stops reading and stays stopped while a close races it. Worth knowing that the stress test is not sufficient on its own. That also caught a follow-on: Gate1601 tests / 165 suites / 0 failures, run twice with no restarts (the hang reproduced within one run before the fix). swiftlint 0 errors, swift-format clean, both daemon schemes build, Linux CI green. Ready for another look at |
Re-review of
|
| Check | Result |
|---|---|
| Full gate | ✅ 1604 tests / 165 suites / 0 failures (your 1601 + three new probes of mine, below) |
| Stall reproduction, my script, deaf client attached | ✅ baseline 0.001 s → 0.006 s with the deaf client → 0.006 s after it leaves |
| Cross-project supersession, end-to-end | ✅ client joined to A and B now receives ['NEEDED', 'Poke'] for A, then B |
Both channel suites, -run-tests-until-failure -test-iterations 40 |
✅ 520 runs, 0 failures, no hang, 46 s — the scenario that hung you twice did not reproduce once |
| Linux CI | ✅ green |
Attacking the new write path
| Seam | Probe | Result |
|---|---|---|
| Partial writes mid-frame | 13 frames of 1 B … 300 KB through a 2 KB SO_RCVBUF/SO_SNDBUF, peer draining in gulps with delays |
✅ every frame intact, in order, none duplicated — pointer advances by exactly written, and EINTR/EAGAIN both leave it in place |
| Close landing mid-frame | 200 KB frame parked, second queued, close after 20 ms |
✅ the peer sees at most complete frames and then EOF — a truncated frame is always followed by shutdown, never by more bytes it could read as a header. That holds for every path that sets isClosing (closeAndWait, the valve, a failed write); detach only ever runs on a channel whose shutdown already went out |
Peer vanishes while the writer is parked in poll |
4 × 200 KB parked, then close(client) with nobody closing the channel |
✅ noticed within the 50 ms slice via POLLHUP/EPIPE; the next send returns false |
| EINTR | by reading: send → continue with the same pointer; poll → loop |
✅ correct; I did not find a way to inject it deterministically |
written == 0 |
falls through the EAGAIN guard → false |
✅ no spin |
Anything else still writing to a connection fd around the channel (the old per-fd writeLocks is bypassed now) |
grep | ✅ only client-side writers remain; the channel is the sole daemon-side writer |
Probes are on review/291-probes-2 (one commit on top of 518856c, appended to the file you took) if you want them.
Two notes, neither blocking
- Key by
graph.idvs the client routing byproject.path. The app keysstate.projects[id: path]; the id is decoded from the persistence file and minted fresh on import/export, so it is 1:1 with a path today. It only stops being so if someone hand-copies aprojects/<slug>.json— then two paths share an id and the cross-project loss quietly returns for that pair. Keying byscope/path would make the daemon and the app agree on identity by construction. Your call. - A dropped-but-alive-and-silent client leaks its connection Task. The valve shuts the socket down, but the connection loop's reader only returns when
shutdownwakes it or the peer closes. Ifshutdownwaking a parkedwriteis unreliable, a parkedreaddeserves the same suspicion; the cost is one leaked fd + Task per hung client, not a stall, so it belongs with ProjectRegistry.removeConnection is reentrant, and the connection registry is keyed by descriptor #292 rather than here.
Add write-path probes for the non-blocking writer (#291) Test-only. #291 replaced the channel's blocking write with non-blocking send plus a bounded poll, because a thread parked inside a blocking write on a unix socket is not reliably woken by another thread's shutdown. That path merged with only its author's tests; these were written to attack it — partial writes across 13 sizes through a 2 KB buffer, a close landing mid-frame ending at EOF rather than in garbage, a peer vanishing while the writer is parked, and the EINTR / EAGAIN / written == 0 branches. Authored by the reviewer on review/291-probes-2, cherry-picked unchanged. Gate: 1604 tests / 165 suites / 0 failures. Linux CI green.
GraphStore.notifyClients looped over connections calling send, and send ran JSONEncoder().encode(event) — so a graph change cost one full encode of the snapshot per connection, presence tick included. The encode is hoisted out of the loop: notifyClients encodes the snapshot once into the bytes and the per-graph superseding key #291 introduced, and hands that to every connection through deliver, which keeps dropping a connection whose channel is gone. send keeps its shape for the unicast callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
GraphStore.notifyClients looped over connections calling send, and send ran JSONEncoder().encode(event) — so a graph change cost one full encode of the snapshot per connection, presence tick included. The encode is hoisted out of the loop: notifyClients encodes the snapshot once into the bytes and the per-graph superseding key #291 introduced, and hands that to every connection through deliver, which keeps dropping a connection whose channel is gone. send keeps its shape for the unicast callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
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.
Bound a blocking send, which MSG_DONTWAIT does not (#291) #291 moved the channel off blocking writes to `send(… MSG_DONTWAIT)` plus a poll loop, claiming no single send could then park and that `closeAndWait` was therefore bounded. The flag does not do that: on macOS it has no effect on `send` for a blocking AF_UNIX stream socket — 60 KB into a 4 KB peer was still inside the syscall after two minutes — so the poll loop never ran and the writer parked exactly as before. The actor was never at risk; that is the writer thread's doing, and #288's stall is fixed and verified against the shipped 0.1.64-beta1 daemon. What was not real was the teardown guarantee: a wedged peer could still hang whoever was disconnecting it, the same bug one layer down from where it was fixed. `SO_SNDTIMEO` bounds sending only, leaving untouched the reader that shares this open file description — which is why `O_NONBLOCK` was rejected and still is. With it a full peer returns a short count after one slice and the existing loop works as written. The comments are corrected in the same change. Three of them described a mechanism that never ran, which is worse than none: the next person here would have reasoned from a false premise. Found by BroadcastSlimming, reproduced twice in Python and once through OutboundChannel before being reported, and confirmed independently before this was written. Gated on the merged result: 1642 tests / 170 suites / 0 failures, no restarts. Linux CI green.
The defect
graphcode mail inboxtimed out against a daemon that was never busy, only blocked.A
graphChangedframe carries the whole graph — 175,937 bytes on the live graph, of which 132,661 (75%) is the mailroom — while an AF_UNIX socket'sSO_SNDBUFis 8,192 bytes. A frame 21× the send buffer cannot be handed to the kernel and forgotten: the blockingwriteonly completes as the peer drains it, repeatedly, mid-frame.Both callers doing that writing are actors. So one client that stopped reading for a moment — which is exactly what the
graphcodeCLI does while it renders its output, holding its connection open underdefer { client.closeConnection() }— parked aGraphStorethread insidewrite(2), and every other command for that project queued behind it until that client happened to exit.Measured against the live daemon over the raw socket, before the fix:
The CLI's own receive budget is 10 s. It freed the instant the wedging client exited, which is why it read as intermittent.
The change
Frames go to a per-connection
OutboundChannelwith a writer thread of its own, so the actor hands a frame over and returns.MSG_DONTWAITwith a boundedpollfor writability. This is not an optimisation: a thread already parked inside a blockingwrite(2)on a unix socket is not reliably woken by another thread'sshutdown, so a wedged peer could hang the disconnecting caller for ever — the same bug, moved from the actor to the connection loop. Per call rather thanO_NONBLOCKon the descriptor, because that flag is shared with the daemon's reader.graphChangedis replaced by a newer one of the same graph — the event carries the whole graph and never a diff. Keyed per graph and not on the event name, because one connection joins many projects over one socket.closestops the writer and waits before closing.openrefuses to inherit a dead channel,sendrefuses an unregistered descriptor, andcloserefuses a descriptor it has no channel for — descriptor numbers are recycled aggressively.Verification
End-to-end against a freshly built daemon in an isolated support dir, seeded to a 200,461-byte frame:
Independently reproduced by a reviewer with its own script against its own daemon: 12.0 s → 0.018 s.
Gate: 1601 tests / 165 suites / 0 failures, run twice with no restarts; swiftlint 0 errors, swift-format clean;
graphcodedandgraphcode-clischemes build; Linux CI green.Review history — worth reading before approving
This PR was wrong three times, and each time something other than the author's judgment caught it. The seams below are where a reviewer's attention is best spent.
SHUT_RDWRasInt; the PR was called green on the macOS gate alone while CI was already red"graphChanged"key let project B's snapshot delete project A's undelivered one, leaving a client permanently stale on Awrite(2)is not woken byshutdown—closeAndWaitcould hang for ever, in the seam an earlier commit claimed to have made safesend(2)only works on sockets; tests attach/dev/nullgraphcode/Tests/OutboundChannelReviewTests.swiftis the reviewer's own probe suite, taken wholesale — it drives the realGraphStorepath and forces genuine descriptor reuse withdup2, and its 150-round close/recycle stress test is what any future change in this seam should have to pass.Scope
Partially addresses #288 — it removes the stall, not the amplification. The bounded mailbox response, the cursor advancing only through delivered posts, and avoiding a full-graph broadcast for a cursor update are tracked separately and already in progress (measured at −74% on the frame). Deliberately not closing #288.
#292 files the remaining structural half of the descriptor-recycling story:
ProjectRegistry.removeConnectionis reentrant and the connection registry is keyed by descriptor rather than connection UUID. Pre-existing, and too large to fold in here.Also for triage: #290 is a byte-identical duplicate of #289 and can be closed.
Related to #288, #289.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9