Skip to content

sync: a report of what both devices hold is answered at once and not lost - #80

Merged
Omar-VF merged 1 commit into
Liquid-co:dev-publicfrom
ChakraFusion:pr/reliable-reports
Oct 17, 2026
Merged

Omar-VF merged 1 commit into
Liquid-co:dev-publicfrom
ChakraFusion:pr/reliable-reports

Conversation

@ChakraFusion

Copy link
Copy Markdown

The cause of the recurring Journey failure, found in the logs #57 now prints (run #385):

  • B pulled "chapter 2" from A, then: could not tell Neva-A that neva is sync-complete: ... context deadline exceeded.
  • A never recorded that B held chapter 2. When B saved chapter 3, A read it as "both sides modified since last sync", raised a conflict, and from then on took nothing from B, while B kept asking A to pull.

The same report timeout shows in #381 (TestCrossingSyncs_AFirstPullIsNotTakenForADifferentSave), there in both directions for over a minute.

Why the report was lost. sync-complete and in-sync tell the other device what both now hold. Each was sent once with a 2 s limit, and the receiving side answered only after recording it. Recording waits for the database, which has a single connection and can be held for seconds by a snapshot or a sync on that device. On a busy device, or a slow CI runner with the race detector, the 2 s ran out and the report was dropped.

The fix, on the LAN path (the relay doesn't wait for an answer):

  • Answer first. The receiving side answers a state-carrying report at once and records it afterwards, still before the handler returns, one report at a time.
  • Send again. The sender sends such a report again until it is answered: four tries of 5 s, with 1, 2 and 4 s between them. Later reports to that peer wait meanwhile, so the order the queue guarantees holds. Progress reports are still sent once.
  • Record once. Every send of a report carries the same reportId, and a report seen again isn't recorded again (the first one arrived; its answer was what got lost). A report without an ID, from an older build, is recorded as before.

Tests (each fails without the change):

  • TestSyncEventReportsThatCarryStateAreSentAgain: a slow first answer to sync-complete gets it sent again, in order and with one ID, while a slow progress report is sent once. Fails with a single try.
  • TestAReportIsAnsweredBeforeItIsRecorded: a report is answered while its recording is held up. Fails on the old handler (the client times out).
  • TestAReportSentAgainIsRecordedOnce: a report sent twice is recorded once; reports without an ID are recorded every time. Fails on the old handler.

Related: #51 covers the other way the base falls behind: after a pull, nothing reported the agreed state at all, so a quick next edit read as a conflict (the soak failures). Its agreed event goes over this same channel; whichever of the two merges second adds agreed to carriesState.

Checks: the unit tests and 21 related e2e tests (Journey, crossing syncs, deletions, soak) pass locally, twice each with the race detector; fork CI is green. One Windows job failed once on TestConflict_RequiresBothSidesToResolve and passed when re-run: an older rare failure (also upstream run #160), a race between one device resolving a conflict and the other device's watcher starting a sync, which this PR doesn't touch. I'll look at it separately.

🤖 Generated with Claude Code

…lost

A device's "sync-complete" and "in-sync" reports tell the other device what
both now hold. Each was sent once with a 2 s limit, and the receiving side
answered only after recording it, which waits for the database, held for
seconds by a snapshot or a sync. On a busy device the report was lost: its
record of what both hold stayed a round behind, and the next save read as
both sides having changed, a false conflict after which nothing more was
taken (upstream CI #385, the recurring Journey failure; #381's reports timed
out both ways for a minute).

- The receiving side answers first and records after, still before the
  handler returns, one report at a time.
- The sender sends a state-carrying report again until it is answered: four
  tries of 5 s, with 1, 2 and 4 s between them. Later reports to that peer
  wait, so the order holds. Progress reports are still sent once.
- Each send of a report carries the same reportId, and a report seen again
  is not recorded again. A report without one (an older build) is recorded
  as before.

Tests: a slow first answer to sync-complete gets the report sent again, in
order, with one ID, while a progress report is sent once (fails with a
single try); a report is answered while its recording is held up (fails on
the old handler); a report sent twice is recorded once, one without an ID
every time (fails on the old handler).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Oct 16, 2026

Copy link
Copy Markdown

@ChakraFusion is attempting to deploy a commit to the sivadaboi's projects Team on Vercel.

A member of the Team first needs to authorize it.

@ChakraFusion

Copy link
Copy Markdown
Author

About the red run #394: neither failure comes from this PR. TestReportedWAN_AlternatingEditsMustNotConflict is #71's push loop, fixed by #78. TestBranchFollow_FetchesTheOtherDevicesSaveNotDeletesIt is the branch-switch problem now reported as #85: A's once-a-minute sync switched it back before B could follow. The other nine jobs passed.

@Omar-VF
Omar-VF merged commit 787810b into Liquid-co:dev-public Oct 17, 2026
19 of 21 checks passed
ChakraFusion added a commit to ChakraFusion/OpenSave that referenced this pull request Oct 17, 2026
…ded at once

After a pure pull the puller records the agreed base; the device it pulled
from recorded the same only when a later fetch of the puller's save found it
unchanged. A puller that edited again first (quick turns, a game saving
right after a sync) left that device with a stale base, and its next sync
read both sides as changed: a false conflict, after which it took nothing
(upstream CI: TestSoak_ManyAlternatingRoundsNeverConflict, Windows, relay).

The puller now sends an "agreed" report with the hash it holds; the other
side records it as the base when it is exactly what it handed over (its
push record), and ignores it otherwise. Older peers ignore the report.

Like "sync-complete" since Liquid-co#80, the report is answered at once and sent
again until it is answered (carriesState), with the same reportId; a
repeat is recorded once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ChakraFusion added a commit to ChakraFusion/OpenSave that referenced this pull request Oct 19, 2026
…ded at once

After a pure pull the puller records the agreed base; the device it pulled
from recorded the same only when a later fetch of the puller's save found it
unchanged. A puller that edited again first (quick turns, a game saving
right after a sync) left that device with a stale base, and its next sync
read both sides as changed: a false conflict, after which it took nothing
(upstream CI: TestSoak_ManyAlternatingRoundsNeverConflict, Windows, relay).

The puller now sends an "agreed" report with the hash it holds; the other
side records it as the base when it is exactly what it handed over (its
push record), and ignores it otherwise. Older peers ignore the report.

Like "sync-complete" since Liquid-co#80, the report is answered at once and sent
again until it is answered (carriesState), with the same reportId; a
repeat is recorded once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ChakraFusion added a commit to ChakraFusion/OpenSave that referenced this pull request Oct 20, 2026
…ded at once

After a pure pull the puller records the agreed base; the device it pulled
from recorded the same only when a later fetch of the puller's save found it
unchanged. A puller that edited again first (quick turns, a game saving
right after a sync) left that device with a stale base, and its next sync
read both sides as changed: a false conflict, after which it took nothing
(upstream CI: TestSoak_ManyAlternatingRoundsNeverConflict, Windows, relay).

The puller now sends an "agreed" report with the hash it holds; the other
side records it as the base when it is exactly what it handed over (its
push record), and ignores it otherwise. Older peers ignore the report.

Like "sync-complete" since Liquid-co#80, the report is answered at once and sent
again until it is answered (carriesState), with the same reportId; a
repeat is recorded once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.

2 participants