Repository navigation
Make sync actually receive, survive an hour, and say when it doesn't - #69
Merged
Merged
Conversation
`crosscode start` worked, published edits, and received none of its peers'. The daemon retried `GET /v1/stream` and got 404 on every attempt: the service runs as a Vercel serverless function, and a serverless function cannot answer a WebSocket upgrade. No amount of reconnecting was ever going to help, and there was no other path in -- so the product was an uploader, not a sync, and `crosscode status` reported `connected: false` with no way for it to ever become true. The receiving half already existed. `catchUp()` drains `GET /v1/changes?since=` and applies each change, and it already ran on every (re)subscribe to close the gap left by a dropped socket. It just had nothing to call it when the socket never opened at all. So a failed stream now starts a poll loop that calls the same catch-up every 3s, and stops the moment a socket opens. Two details worth naming: `connected` now means "in touch with the service by either transport", which is what `crosscode status` should report. Presence asks the new `streaming` instead, because presence exists only on the socket -- a polling room goes quiet about who is where, but it is not blind to edits. The poll loop guards re-entry on `pollActive` rather than on the timer handle. The timer is unset while a tick is in flight, and the socket fails once per backoff, so starting a second loop in that window would have been the normal case rather than the rare one. ## Tests `sync-service-stub` gained a `streams: false` option that omits the upgrade listener entirely, which is what makes node destroy the socket -- the client's view of a host that does not serve WebSockets. The new test stands two real daemons up against it and asserts an edit crosses in both directions. Verified it fails without the fix: it times out waiting for either daemon to report itself connected. `pnpm build` clean, `pnpm test` 215 passed / 11 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found by running the workflow against production rather than against the stub. Each was hidden behind the last, and together they made a checkout unusable roughly an hour after sign-in with no way to recover from inside the CLI. ## The session was never refreshed Supabase access tokens last an hour. The refresh token sat in the config next to the expired access token and nothing ever spent it, so `register()` threw "Access token is invalid or expired" and took the whole daemon down. Every authenticated call now goes through `authorization()`, which refreshes a minute ahead of expiry and hands the refreshed session back to be written to disk -- Supabase rotates the refresh token on use, so persisting it is what makes the *next* start-up work. Concurrent callers share one refresh through a promise, because two would each spend a token the other had already rotated away. ## `start` reported a daemon that was already dead `crosscode start` polls for the descriptor every 100ms and reports success the moment it appears. It was written when the loopback server bound -- before the stream, the watcher, or the first publish -- so a daemon that then died on the expired session still had `start` printing "Daemon started; this checkout is syncing" about a process that was gone a second later. The descriptor is now published last, once start-up has actually succeeded. ## And there was no way out of the loop The daemon told the user to run `crosscode start`. `start` saw a session on disk, said "Already signed in for this checkout", and started the same doomed daemon again. "There is a session" and "this checkout can talk to the service" are different questions; `start` now asks the second one, refreshing if it can and signing in again if it cannot. An expired sign-in also exits with that one line instead of a stack trace. ## Tests - The refresh path: refreshes when expired, leaves a live token alone, and spends the refresh token exactly once under three concurrent calls. - The descriptor: against a service that accepts and never answers, no descriptor exists while start-up is in flight. Verified it fails without the fix. - `start`: signs in again when the refresh fails, and reuses (and stores) a refreshed session without a second sign-in. `streamPollMs` joins the other tunable intervals so the polling test does not wait out real 3s cycles. `pnpm build` clean, `pnpm test` 219 passed / 11 skipped. Two tests in `sync-daemon.test.ts` flake under full-suite CPU contention on a loaded machine -- always as timeouts, never assertions. Confirmed pre-existing: `main` fails the same way at the same rate with none of this applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed poll set `connected: false` and discarded the reason. The daemon then sat there with a cursor that never advanced and nothing on stderr -- which is exactly the state a production outage put it in, and the reason diagnosing it needed a patched build rather than a log line. Reported once per run of failures rather than every 3s, and reset when a poll succeeds, so a flapping service does not fill the terminal. This is what surfaced `GET /v1/changes` answering 500 with `relation "terms_acceptances" does not exist` -- a migration that shipped in the deploy but was never applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
apply() calls catchUp() to fill a gap when a peer's change is built on a blob this checkout has never held. Called from inside a drain, catchUp re-walked the same page from the same cursor -- and the cursor cannot move past a change that has not applied, so it never moved. The retry was already guarded: allowCatchup: false on the second receive exists so a second miss surfaces rather than loops. But the fill in front of it was not, and that is where the recursion was. In production this was one peer asking GET /v1/changes several times a second, indefinitely, while crosscode status showed a cursor frozen at 1 and reported no error at all. A fresh checkout that joined a project it could not fully rebase never received anything again. catchUp is now non-re-entrant: a nested call is a no-op, because the drain already running is the fill. The retry then decides, and an honest conflict reaches the agent. The stub counts GET /v1/changes, and the test asserts a daemon holding a change whose baseHash names a blob that exists nowhere still advances its cursor and then goes quiet. Verified it fails without the guard: the cursor never advances and it times out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bouncing one file straight back landed the reply inside the hot window the first write opened, so under CI's load it was a deferral test wearing a transport test's name -- and it failed there while passing locally in under two seconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first time two real checkouts joined one project, the teammate arrived holding three conflicts before typing anything: .mcp.json, .claude/settings.json, and the crosscode SKILL.md. Every checkout installs its own copy, so no two peers share an ancestor for them. They could never converge either, because `crosscode start` rewrites them locally on every run -- so any resolution is undone by the next start. And they are not content in the sense the rest of the sync means: they are per-machine setup, regenerated on demand by the command the invite already tells a teammate to run. Nothing is lost by leaving them out, and nothing was fixable by leaving them in. Added to isDenied, which is already the one choke point checked on the way out and on the way in. The rest of .claude/ still syncs -- a team's own skills and commands are theirs to share, and only the three paths Crosscode itself writes are excluded. Verified live: the two checkouts that were holding these three conflicts came back up with zero, and still sync in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng-fallback # Conflicts: # apps/cli/src/setup.test.ts # apps/cli/src/setup.ts
This branch was successfully deployed
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.
Three rounds of fixes, each found by running the workflow against production rather than
against the stub, and each hidden behind the one before it.
The stream was the only way in
The service runs as a Vercel serverless function, which cannot answer a WebSocket upgrade,
so
/v1/stream404s on every attempt and no amount of reconnecting would ever help.Publishing worked; receiving did not. That is an uploader, not a sync.
The receiving half already existed --
catchUp()drainsGET /v1/changes?since=andalready ran on every resubscribe. It just had nothing to call it when the socket never
opened. A failed stream now polls that same route every 3s and stops the moment a socket
opens.
connectedmeans "in touch by either transport"; presence asks the newstreaming,because presence only exists on the socket.
The session was never refreshed
Supabase access tokens last an hour. The refresh token sat in the config next to the expired
access token and nothing ever spent it, so
register()threw "Access token is invalid orexpired" and took the daemon down. Every authenticated call now refreshes a minute ahead of
expiry and persists the result -- Supabase rotates the refresh token on use, so persisting
is what makes the next start-up work. Concurrent callers share one refresh.
startreported a daemon that was already dead, then refused to fix itThe descriptor was written when the loopback server bound, before the stream or the first
publish, and
startpolls for exactly that file -- so it printed "Daemon started; thischeckout is syncing" about a process that died a second later. It is now published last.
Worse, that daemon told the user to run
crosscode start, andstartsaw a session on disk,said "Already signed in for this checkout", and started the same doomed daemon again. There
was no way out of that loop. "There is a session" and "this checkout can talk to the service"
are different questions;
startnow asks the second one.And it now says when it stops
A failed poll set
connected: falseand discarded the reason, leaving a stuck cursor and asilent terminal. It reports once per run of failures.
Verified live against production
Sign-in,
crosscode start,crosscode inviteproducing a link, and a second checkoutjoining through that link with its GitHub repo access verified -- all confirmed end to end,
which had never been done before. The joining checkout also raised a genuine conflict on
README.mdand quarantined it for its agent rather than overwriting.Two-way file sync is not confirmed, and not because of anything here: every
authenticated route is currently answering 500 with
relation "terms_acceptances" does not exist. That table arrived with #66 and its migration was never applied to production. Itneeds
pnpm service:migrateagainst the productionDATABASE_URL.Tests
215 -> 219. New: an edit crossing both ways against a stub that refuses upgrades entirely
(verified it fails without the fix); refresh when expired, leave a live token alone, and
spend the refresh token once under three concurrent calls; no descriptor while start-up is
in flight (verified it fails without the fix); sign in again when the refresh fails, and
reuse a refreshed session without a second sign-in.
Two tests in
sync-daemon.test.tsflake under full-suite CPU contention on a loadedmachine, always as timeouts and never assertions. Confirmed pre-existing:
mainfails thesame way at the same rate with none of this applied.
🤖 Generated with Claude Code