Ship the presence tick as the loops it moved, not the whole graph (#288) - #297
Conversation
dd3e723 to
be549b0
Compare
4392e12 to
02935b3
Compare
Independent review of #297 at
|
| Attack | Result |
|---|---|
| Client misses a delta and never recovers | Can't get there. Deltas are never superseded (supersedingKey == nil), and the only other way a frame is dropped is the backlog valve — which calls beginClosingLocked(), so the connection dies, send returns false, GraphStore forgets the client, and it reconnects to a fresh snapshot. A delta is delivered or the socket dies; it is never silently skipped. |
| Snapshot superseded while a delta queues behind it | Correct in both queue orders. Replace-in-place → client gets the newer snapshot then drops the older delta on revision >. Append-at-tail → client applies the delta, then the snapshot overwrites it. Converges either way. This is precisely what the revision stamp buys and it earns its place. |
| A node deleted rather than changed | Safe. moved never carries a deletion, and applying only replaces ids the client already holds — so a delta arriving after a deletion snapshot can't resurrect the node. The where merged.nodes[id:] != nil guard is load-bearing; please keep the comment on it. |
| Client joins mid-tick | Safe. addConnection stamps the joining snapshot with the current revision and notifyClients(nodesChanged:) increments before encoding, with no await between the filter and the send — so the tick is atomic against a join. |
| App and daemon disagree about the revision | Couldn't produce it. Snapshots are applied unconditionally and carry the daemon's number, so the app's held revision is always a daemon number and can never run ahead. A daemon restart resets the counter to 0, but it also drops the socket, and the rejoining snapshot re-stamps the app at 0 before any delta arrives. Stores are cached by path and never replaced within a daemon lifetime, so the counter can't reset under a live connection. |
Actor reentrancy on the stale before |
Harmless. before is captured before four awaits, so an interleaved command can make moved carry nodes that already went out in a snapshot — redundant, never wrong, and applying merges by id. |
| CLI / remote shim | Both skip unknown frames by construction (waitForEvent's try? + continue; the shim's wait_for key match). mail watch registers and exits rather than long-polling, so nothing burns the 64-frame budget on 15-second ticks. The CLI is genuinely more correct than before — a presence tick can no longer be mistaken for a command's acknowledgement. |
Payload claim — reproduced, and it's better than the PR body says
Measured against this repo's own persisted graph (34 loops, 215 posts), post-#293 wire shape:
| Frame | Bytes | vs a snapshot |
|---|---|---|
graphChanged (wire snapshot) |
63,482 | — |
nodesChanged, 1 median loop |
1,155 | 1.8 % |
nodesChanged, 3 loops |
1,692 | 2.7 % |
nodesChanged, 5 loops |
2,755 | 4.3 % |
nodesChanged, 10 loops |
9,137 | 14.4 % |
The PR body's "~1.5 KB instead of the snapshot" is accurate; a fifteen-second tick that moves one loop is a 98.2 % reduction. Worth noting the shape for the record: the delta only wins while few loops move, and crosses back over the snapshot somewhere past ~30 of 34 — which is fine and expected, just not stated anywhere.
Two small things
guard !moved.isEmpty else { return } swallows a broadcast the old code sent. If changed is true but no top-level node value differs, nothing goes out at all where a full snapshot used to. I could not construct a reachable case — all four refreshes only write graph.nodes[id:] fields — so I read this as a correct safety net rather than a bug. Flagging it because the comment above it asserts "the diff is exact" and that assertion is doing real work: it stops holding the moment anything in that tick touches graph.edges, graph.mailroom or a graph-level field. Worth a line in the comment saying so, for whoever adds the fifth refresh.
This commit carries an unrelated change from another PR. graphcode/Tests/MailboxTests.swift — splitting the suite into an extension for swiftlint's type_body_length — is #295's test file and #295's problem; #297 adds nothing to it. The rebase needed it to stay green, which is understandable, but a PR carrying another PR's change is how a revert later takes something unexpected with it. Either split it out or say so in the body.
Verification I ran myself
- Full Xcode gate: 1619 tests / 169 suites / 0 failures, run by me to completion — on
4392e12, the pre-rebase tip. I want to be exact: I have no full gate on02935b3e. The only difference between the two is theMailboxTestsswiftlint split above, so I'm treating the number as representative, but I'm not going to imply I gated the current tip when I didn't. (I killed a second full-gate run part-way on a load request; its wrapper exited 0 whilexcodebuildexited 143, which is not a result.) - Focused run on
02935b3e:BroadcastDeltaReviewTests, 2 tests — 1 passed, 1 failed as quoted above. - Linux CI green on
4392e12; in progress on02935b3ewhen I looked. - All four Never block the daemon actor on a client write (#288) #291 invariants re-verified intact after both PRs — details in my Encode a broadcast once, not once per connection (#288) #296 comment.
Verdict: request changes. The delta design is right, the revision stamp genuinely earns its place, and the payload win is real and larger than claimed. But nodesChanged changes what a graphcoded connection is — a push channel — and the app's read loop was never built for that. Make an undecodable frame skippable and loud, then I'd merge it.
Probes: branch review/296-297-probes, one commit 0cd9e40 on top of 02935b3e — take them wholesale if they're useful.
🤖 Generated with Claude Code
be549b0 to
5a42063
Compare
5c2f280 to
fb78bab
Compare
The poll runs every fifteen seconds and broadcast the whole graph whenever anything moved — on a busy graph, almost every tick. It now sends DaemonEvent.nodesChanged: the top-level loops whose reading, activity, summary or board changed, as whole values, which is an exact diff because that is all the poll edits. Every frame a store sends carries a revision (LoopGraph.revision, wire-only) so a client can hold snapshots and deltas in one sequence and drop a delta that a superseding snapshot overtook. The app folds a delta into the snapshot it holds and handles the result as a snapshot. Version skew: a connection is sent only the events it announced it can read. DaemonEvent.requiredCapability is exhaustive — a new case does not compile until its author decides whether an older client may receive it — and GraphStore.deliver enforces it, so the daemon's default for a client that never announces (every app already in the field) is no new event type; those get the whole snapshot on the tick as they always did. DaemonCommand.announce(capabilities:) is the first frame the app puts on every socket. And the app's reader skips a frame it cannot decode instead of taking it for a dead socket, saying so once per connection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
fb78bab to
bd60719
Compare
Stacked on #296 (encode once), both now based on
mainpost-#293.The defect
The presence poll runs every fifteen seconds and, when anything moved, broadcast the whole graph — 50 KB after #293 — to every client. On a busy graph something moves almost every tick, so the tick was the daemon's steady-state cost: a full snapshot per project per client, four times a minute, to say which pill changed colour.
The change
The tick ships the loops it moved and nothing else.
DaemonEvent.nodesChanged(projectPath:revision:nodes:)carries the top-levelLoopNodes whose reading, activity, summary or board changed on this tick, as whole values — the diff is exact because everything the poll edits is a field on a top-level node. A tick that moved nothing sends nothing, as before; a tick that moved one loop is ~1.5 KB instead of the snapshot.GraphStorestamps snapshots and deltas from one counter (LoopGraph.revision, wire-only likemailroomDigest). That is what makes deltas safe beside Never block the daemon actor on a client write (#288) #291's superseding: an undelivered snapshot can be replaced by a newer one while a delta queued behind it still arrives — the client sees the delta is older than the graph it holds and drops it. Deltas themselves are never superseded (a newer one does not carry what an older one said).LoopGraph.applying(nodesChanged:revision:)) and then handles the result as a.graphChanged, so every reader of a snapshot — the activity log, the open workspace, the project reducer — keeps one shape. A delta never invents a loop. The CLI and the remote shim ignore the event, as they ignore any frame that is not the acknowledgement they wait for; that is more correct than before, when a presence tick could be mistaken for one.Version skew (from ReviewBroadcastDelta)
nodesChangedwould have been the first event a daemon broadcasts unasked, and the app's reader took an undecodable frame for a dead socket — a 0.1.63 app against this daemon would have torn down and rejoined every fifteen seconds for ever. Two fixes, both here:DaemonCommand.announce(capabilities:). The first frame the app puts on every socket, dial and redial alike, says what it can read (ClientCapability.nodesChanged). A store sends the delta only to a connection that announced it and the whole snapshot — same tick, same revision — to every other, so an older app keeps getting exactly what it always got. The registry forwards a late announcement to stores the connection already joined, so which of the launch's frames lands first does not decide anything. Unknown capability names are ignored. This is the general answer for every future event, not only this one (Helper install order puts a new daemon in front of an old CLI, so a protocol change can lose data silently #298).graphcodedtreats a command it does not know (main.swift's "unrecognized command" reply).Tests: a tick reaches an announcing connection as a delta and a silent one as a snapshot with the same revision and no posts; the client announces first on a fresh socket and again on a redial; an unknown frame is skipped and the next readable event arrives over the same connection (one accept, no redial).
Verification
Gate: full Xcode suite (1619 tests in 169 suites, 0 failures), swiftlint 0 errors, swift-format clean,
graphcodedandgraphcode-clischemes build; SwiftPMswift buildand.build/debug/graphcodepass locally.PresenceDeltaTests: over a socket, the first tick ships both loops (first readings), the second ships only the one that moved with a higher revision, the third ships nothing, and a rename after that is a whole snapshot stamped later still;applyingmerges by id and never invents a loop; the app folds a delta into its snapshot and an open workspace's node, and drops one older than what it holds.Refs #288
🤖 Generated with Claude Code
https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N