Skip to content

feat(collab): the hub is the only writer while a room is live - #260

Merged
ssowonny merged 2 commits into
mainfrom
feat/hub-is-the-only-writer
Sep 23, 2026
Merged

ssowonny merged 2 commits into
mainfrom
feat/hub-is-the-only-writer

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

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. OnUpdate returns 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 on destroy, where a parting write would be one more racer. dirty clears honestly: when the hub's write comes back through the change stream and merge() finds the file equal to the document. A client that never reaches live is solo and saves exactly as before.

Tests

Server (ycollab_writer_test.go), each seen to fail with its mechanism removed:

test removed → fails
writes itself after a pause watchRoom no-op
writes by the deadline while typing never pauses watchRoom no-op
parks its version when someone outside wrote no If-Match → "snapshot overwrote an outside write"
seeding a room is not a write —
dropped room does not 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 (routeWebSocket refuses the upgrade; the editor falls back after UNREACHABLE_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 expect ran straight into my test's header comment, so one function swallowed the other and the spec didn't parse. tsc never sees e2e/, 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

Base automatically changed from fix/collab-broadcastchannel-crosstalk to main September 23, 2026 06:42
ssowonny and others added 2 commits September 22, 2026 23:43
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
ssowonny force-pushed the feat/hub-is-the-only-writer branch from 16098de to 70f0139 Compare September 23, 2026 06:43
@ssowonny
ssowonny merged commit 75b2689 into main Sep 23, 2026
7 of 8 checks passed
@ssowonny
ssowonny deleted the feat/hub-is-the-only-writer branch September 23, 2026 07:02
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant