feat(collab): the hub is the only writer while a room is live - #260
Merged
Merged
Conversation
Base automatically changed from
fix/collab-broadcastchannel-crosstalk
to
main
September 23, 2026 06:42
Every co-editor used to write the file themselves, 700ms after their own last keystroke, each against whatever If-Match they last saw. With one document shared by N people that is N writers racing for one file, and every lost race parked somebody's text beside the file as a "conflict" that was the file against itself. Two rounds of client-side retry logic (#257, #258) did not stop it, because the race is between reading the base and the write arriving, and no amount of care about timing closes that. So nobody in the room writes. The hub holds the document, subscribes to every update through crdt.Doc.OnUpdate, and writes ONCE per pause: two seconds after the last change, or ten seconds after the first if the typing never pauses — the numbers Hocuspocus settled on for onStoreDocument, for the same reasons. The client, once its room is live, never arms its save timer again; a client that never reaches "live" is solo and saves exactly as before. One writer changes what an If-Match refusal MEANS. It can no longer be a co-editor — everyone in the room shares this document — so it can only be an agent, the CLI, or a device writing the file underneath the room. That case gets the conflict copy it deserves, made by the hub and attributed to the human who was editing, and the room rebases so it is not refused for the same stale base forever. Nothing is silently overwritten on either side. Folding the outside write INTO the live document is the better answer and is the hub's to give — it is the one place the splice could be made exactly once — and is filed, not done. Seeding subscribes AFTER the bytes are in, so the document a room was built from is never mistaken for an edit that needs writing back. Dropping a room disarms its timer and unsubscribes. The debounce constants are vars so a test can shrink them; production never sets them. Five server tests, each seen to FAIL with its mechanism removed: no watcher (two tests), no If-Match (one), and the two guards. Race-clean. The e2e gate — no client PUTs in a live room, file still updates, status line goes clean when the hub's write comes back — is WRITTEN AND UNRUN: Chromium cannot launch on this machine right now. It is not believed until it has been watched to fail against the previous bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two forced-409 tests (#257, #258) exercise the client's own save-and- retry. That path now exists only when the hub does NOT hold the document — in a live room the hub is the sole writer and the client never PUTs, so there is no save to refuse, and both tests failed on their own guard: "the 409 was never delivered, so nothing was tested". That guard did its job. They now refuse the websocket upgrade (routeWebSocket) so the editor gives up on the room after UNREACHABLE_MS and mounts solo — the older-hub / no- websocket-proxy case the retry was kept for. Verified both ways: with the retry removed, each still fails on the conflict copy it guards. Also closes #258's test body, which the rebase's union resolution had left open: #258's final expect was immediately followed by the next test's header comment, so one function swallowed the other and the file did not parse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ssowonny
force-pushed
the
feat/hub-is-the-only-writer
branch
from
September 23, 2026 06:43
16098de to
70f0139
Compare
ssowonny
added a commit
that referenced
this pull request
Sep 23, 2026
…ch box true (#261) The stage's list was marked done in #246 on the strength of its first item. "The client defers the write to the hub" and "a periodic snapshot while a room is live" were ticked and not built — a script replaced every "- [ ]" in the stage, and nothing re-read the list against the code. That gap is the direct cause of every conflict copy produced between #246 and #260: the 0-byte report, the 8x-duplicated document, forty-four copies. Two PRs (#257, #258) patched the symptom because the PRD said the cause was already fixed. #260 built the two missing items. Each box now names the PR that made it true. A tick is a claim. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
If-Matchrefusal can now only mean someone outside the room wrote.mainwhen that merges.Why the previous fixes couldn't finish the job
#257 handled "ours ⊇ theirs" and #258 handled "theirs ⊇ ours" — both correct, both still racing. The window is between the client reading its base sha and its PUT arriving, and no care about when a peer's sha is adopted can close a window that's already open. Fixing the race meant removing the racers.
This was in the PRD as Stage 4 ("the client defers the write to the hub entirely… a periodic snapshot while a room is live"). I ticked those boxes and implemented only the local-edits half. This PR is the other half.
What the hub does now
crdt.Doc.OnUpdate(ygo already exposes it) arms a per-room timer: 2 s after the last change, never later than 10 s after the first.OnUpdatereturns immediately — nothing on it may block or re-enter the doc — so it only arms the timer; the write runs on the timer's goroutine. Subscribing happens after seeding, so the bytes a room was built from are never mistaken for an edit to write back.An outside writer is never silently overwritten. With one writer, a 409 can't be a co-editor — everyone in the room shares this document. It can only be an agent, the CLI, or a device. Their write stays the file; the room's version is parked beside it under the sync path's conflict name, attributed to the human who was editing; then the room rebases so it isn't refused for the same stale base forever. Nothing lost on either side. Folding the outside write into the live document is the better answer and is the hub's to give (it's the one place the splice could be made exactly once) — filed, not done.
What the client does now
Once the room reports
live, it never arms its save timer again — including ondestroy, where a parting write would be one more racer.dirtyclears honestly: when the hub's write comes back through the change stream andmerge()finds the file equal to the document. A client that never reachesliveis solo and saves exactly as before.Tests
Server (
ycollab_writer_test.go), each seen to fail with its mechanism removed:watchRoomno-opwatchRoomno-opIf-Match→ "snapshot overwrote an outside write"e2e —
in a live room no client writes the file, and the file still updates: old client bundle fails with exactly "clients in a live room wrote the file themselves: A"; new one passes.#257/#258's forced-409 tests were rightly broken by this — they failed on their own "409 was never delivered" guard, because a live-room client no longer saves. They now run in solo mode (
routeWebSocketrefuses the upgrade; the editor falls back afterUNREACHABLE_MS), which is the case the retry was kept for. Both re-verified: with the retry removed, each still fails on the conflict copy it guards.One rebase artefact worth knowing
The union merge of #258's spec and mine left #258's test body unclosed — its final
expectran straight into my test's header comment, so one function swallowed the other and the spec didn't parse.tscnever seese2e/, so it passed the typecheck; Playwright caught it. Fixed in the second commit.What this doesn't do
The room is single-instance state; two hub instances would hold two documents per file. That's Stage 11, still blocked as recorded in the PRD — nothing here moves the instance cap.
🤖 Generated with Claude Code