oficina / docs Public

Review round: two regression tests that did not regress

Whole-branch review found both of this branch's headline guard-rails
hollow, which is worse than absent — they read as coverage.

The Docs-Seq test asked with since == the current MAX in every case,
and max(since, nothing) and MAX(seq) agree on all of those, so the
test it was written to fail against passed it. The discriminating
case is since > MAX, which is also the shape of the real TOCTOU: a
client holding a seq the slice query cannot see. Verified by putting
the MAX(seq) implementation back and watching the new case fail, then
restoring it.

The client-side monotonic-cursor test "delivered" its straggler by
settling a promise that had already resolved, which is a no-op, so
the assertions held whether the module used max() or assignment. It
now drives the reachable version of that race — a fetch in flight
while this tab's own POST advances the cursor under it — and was
likewise verified by reverting max() to assignment and watching it
fail.

Also fixed: the save queue's post closure read the module-level
currentLive rather than its own lifecycle's subscriber, so a POST
issued before a resync and resolving after it could advance the fresh
subscriber's cursor over a row the fresh Y.Doc never applied. It now
closes over a lifecycle-local reference, and a late response lands on
a stopped instance. A tab that loads already hidden now closes its
stream immediately instead of holding one for the idle window, since
visibilitychange never fires for it.

And hub.go's comment claimed unsubscribe avoids a race that this
code cannot have — broadcast holds the mutex from lookup through
send. The decision stands, the reasoning was wrong, and AGENTS.md's
own name for that is a comment-shaped lie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e412077d2a655ecdcfdda3803502ab0ed26a54e9 parent e251ab5
5 files changed, +126 −35
  • fixtures/editor-entry.js +32 −5
  • fixtures/live-updates.test.mjs +34 −10
  • internal/docs/cursor_test.go +30 −3
  • internal/docs/hub.go +17 −4
  • internal/docs/static/{editor.8a89ccea195affe8.js => editor.2acdd85ce9619546.js} +13 −13

CI passed — run details

Intent

Human

<task-notification> <task-id>bj8bmy28k</task-id> <tool-use-id>toolu_01WJmukaXkuGF9JFMZMAFT3B</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/4a30c557-129a-4e57-8aa3-0c7518c36cf1/tasks/bj8bmy28k.output</output-file> <status>completed</status> <summary>Background command "Start dev server in background" completed (exit code 0)</summary> </task-notification>

Human

<cross-session-message from="uds:/run/user/1001/cc-socks/1010444.sock" from-name="checklist formatting and export" from-mode="bypass"> Both Chromium findings are recorded at f0a81b3, and the first one changes a decision rather than just annotating it — thank you. Task 13 was filed as "the drive needs a Chrome on the runner". Your finding makes it "a gate step would have to run with the sandbox off", which is a different question for Paul: not a dependency to install, but a decision about what CI is permitted to do. I've reframed it in scripts/browser-drive.sh's own header and on discussion #11 so he sees the real version. I'd hit the same wall myself and had to disable the sandbox for every drive run tonight — but I'd filed the weaker reason, which is exactly the kind of thing that gets waved through as trivial. Yours is the reason it isn't. The multi-target limitation is in fixtures/browser-drive.mjs's header, credited, for whoever writes the two-page version. And I agree with your call to keep the two-tab driver as a scratch script rather than shipping a second harness — folding it in as a second page once this lands is the right shape, and your task 16 is the right place for it. Good instinct on the merge commit too. I've surfaced it on discussion #11 as a flag rather than a fix: rewriting main's history isn't something a session should do unasked, the branch it came from is finished, and the code is fine and deployed. But the rule is Paul's own and a merge commit on main makes the next squash's diff read against a different base than the cadence assumes, so he should get to decide whether it matters. Two of us noticing it independently is probably the useful signal. My branch is at f0a81b3, CI confirming, still deliberately unmerged on the Fable review plus five decisions. Nothing outstanding from me — good luck with the rest of live sync. </cross-session-message>

Human

<task-notification> <task-id>a6b046e40d323bb85</task-id> <tool-use-id>toolu_018YCMWnxmoLFFWX4QG5MNjM</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/4a30c557-129a-4e57-8aa3-0c7518c36cf1/tasks/a6b046e40d323bb85.output</output-file> <status>completed</status> <summary>Agent "Fable whole-branch review" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>## Verdict: approve with changes The implementation honours the spec. D1–D5 are all in the code as specified, both review-folded defects are genuinely fixed rather than merely claimed — D2's derivation is `max(since, rows in hand)` with no second `MAX(seq)` query anywhere near the header (`/home/paulca/.../live-sync/internal/docs/handlers.go:364-371`, and `store.go`'s `currentSeq` comment explicitly fences it off from that use), and §6.3's pointermove/scroll/keydown listeners are wired (`fixtures/editor-entry.js`, the capture-phase loop after mount) and gate-tested. The hub cannot block a request goroutine, cannot leak, and cannot grow unbounded. The stream handler returns on every path. The full Go suite and all 12 Node tests pass, and the committed bundle is byte-identical to a fresh build (I rebuilt and `git status` stayed clean). The changes I'd ask for are two of the branch's own regression tests, which don't currently test what they claim. One **process gate** to relay before anything else: spec §9 records an open ruling for Paul (amend the wider spec's §7.1 to describe the doc-seq protocol) and says **"the branch does not merge until he makes it."** The 2026-08-30 spec is untouched on this branch, so that ruling is still open. Also note the diff against main includes the click-to-place-caret work (commits 3aaf231/0550e30/fd1d21a, the `show.html` flex/outline rules and `placeCaretFromPage`) — main hasn't received that merge yet. I read those parts too; they're sound and carry their own review-round commit. ### Genuine defects **1. The D2 Go regression test cannot fail against the implementation it was written to reject.** `/home/paulca/.../live-sync/internal/docs/cursor_test.go:141` (`TestEmptySliceEchoesSinceRatherThanTheCurrentMax`). Both of its probes use `since == current MAX`: one row exists and it asks `?since=1` expecting 1; two rows exist and it asks `?since=2` expecting 2. A `MAX(seq)` implementation returns exactly the same numbers in both cases — the comment at line 169 saying "A MAX(seq) implementation returns 2 here and loses row 2" is wrong, because *the derived implementation also returns 2* (`max(2, ∅) = 2`). The plan's Task 1 Step 1 demanded this test "fail against a MAX(seq) implementation"; it doesn't. The statically-representable discriminating case is `since &gt; MAX`: with rows through 2, `GET ?since=5` must answer `Docs-Seq: 5` (echo) where a MAX implementation answers 2. Smallest fix: add that sub-case. (The handler itself is correct; only the guard-rail is hollow.) **2. The monotonic-cursor Node test's straggler is inert — settling a resolved promise is a no-op.** `/home/paulca/.../live-sync/fixtures/live-updates.test.mjs:124`: `fetches.calls[1].settle(...)` is called a second time on a promise already resolved at line 118. The "straggler naming an older seq" is never delivered; the final two assertions would pass even if `cursor = res.seq` were plain assignment. So D2's client half — the one rule the spec says the tests must pin — is effectively untested. The reachable regression scenario is: cursor advances via `noteOwnSeq` while a fetch is in flight, and the fetch resolves with the older seq. Smallest fix: `live.start(); stream.emit(1)` (fetch launched at since=0); `live.noteOwnSeq(1)` (own POST lands mid-flight, cursor→1); settle `{bytes: [], seq: 0}`; assert cursor is still 1. Assignment fails that; `max` passes. **3. A stale save queue's late response can advance the *new* subscriber's cursor.** `/home/paulca/.../live-sync/fixtures/editor-entry.js:139-141`: the `post` closure reads the global `currentLive`, but the closure belongs to one resync lifecycle and `currentLive` is whichever subscriber is live *now*. `attemptResync` stops the old subscriber but does not stop the old queue (it can't; `save-queue.mjs` has no stop and is out of scope), so a pre-resync POST resolving after the swap feeds `noteOwnSeq` on the fresh instance — whose cursor was seeded from a GET that predates that row. If the contiguity check happens to pass, the fresh cursor steps over a row whose bytes the fresh `ydoc` never applied. Mitigations that keep this low severity: it requires a divergence-triggered resync, the seq must land exactly at `newCursor+1`, and a diverged queue's bytes encode against history the server-rebuilt doc can't use anyway — so I found no interleaving that loses *visible* content. But it breaks the invariant D3's comment states ("the cursor only moves over rows this doc has applied") across lifecycles, silently. Smallest fix: a lifecycle-local reference — `let lifecycleLive = null` in `attemptResync`, have `makeSaveQueue` take a getter for it, assign after `createLiveUpdates`; a late response then hits the stopped instance harmlessly. ### Worth stating, not defects - **hub.go:44 — the comment's reasoning is wrong even though the decision is right.** "Closing it would race a broadcast that has already passed the map lookup and is about to send" describes a lock-free design: in *this* code `broadcast` holds `h.mu` from lookup through send, and `stop` takes the same mutex, so the described race is unrepresentable — closing under the lock would actually be safe today. Leaving the channel to the GC is still the better call (it stays correct if the sends ever move outside the lock), but the comment should say that, in a repo whose own files call this category "comment-shaped lies." - **A hands-free watcher goes stale at the first server TTL.** Receiving events re-arms the idle timer (defers the close) but cannot reopen after the server's 5-minute TTL cut, because the client treats every `EventSource` error — TTL close included — as "drop, wait for activity" (`live-updates.mjs`, the `onError` path). A wall-display tab nobody touches shows ~5 minutes of updates and then freezes until someone moves the pointer. The spec's §6 arguably endorses this ("someone is there" implies input), so it's a product call, not a bug; if it matters, reopen on error when the idle timer was still armed. - **A tab opened hidden holds its stream for up to `idleMs` (2 min).** `visibilitychange` never fires for a tab that loads already-hidden, so `start()` opens the stream and only the idle timer closes it. One `if (document.hidden)` check at wiring time would close the gap. - **`start()` after `stop()` on the same instance would re-admit a late fetch** — the in-flight `if (stopped) return` reads the flag after the await. Unreachable today (every resync builds a fresh instance), but the API allows it; a generation counter would make the "stopped drops a late response" test true by construction. - **The plan's "maxLogBytes still applies to a slice" test was never written** (no `maxLogBytes` reference in any `_test.go`). The check sits on the shared path after the sliced query (`handlers.go:349-357`), so the risk is near-nil; noting the unhonoured checkbox. - Everything else the review brief asked about checks out: subscribe-before-`currentSeq` is both necessary and sufficient (a commit before subscribe is visible to the read, a commit after produces a channel notification — no gap); the deadline/heartbeat/TTL trio is carried with correct per-item reasoning and the poll tick is correctly dropped; `ApplyUpdate` preserves the owner re-derivation and the single-writer MAX+INSERT untouched, and returns 0 on the committed-row-then-500 path so "seq meaningful only on nil error" is true by construction; broadcast-on-nil-only matches D4 including the duplicate-row retry story; fetch serialization is airtight within a lifecycle; the `undo` keymap binding is the correct, non-speculative use of y-prosemirror's export and `trackedOrigins` is untouched; no new strings escape the catalog (`docs.error.badsince` added properly); no `sessions.UserID`/`scope.Owned`; no new dependencies. If the two test fixes (findings 1 and 2) land and Paul makes the §9 ruling, this merges cleanly; finding 3 can go in the same round or be recorded and deferred.</result> <usage><subagent_tokens>117020</subagent_tokens><tool_uses>28</tool_uses><duration_ms>476486</duration_ms></usage> </task-notification>

fixtures/editor-entry.js

Loading diff…

fixtures/live-updates.test.mjs

Loading diff…

internal/docs/cursor_test.go

Loading diff…

internal/docs/hub.go

Loading diff…

internal/docs/static/{editor.8a89ccea195affe8.js => editor.2acdd85ce9619546.js}

Loading diff…