refactor: redesign sync around per-device peers, pure rules, and a single state snapshot#1304
Draft
gmaclennan wants to merge 23 commits into
Draft
refactor: redesign sync around per-device peers, pure rules, and a single state snapshot#1304gmaclennan wants to merge 23 commits into
gmaclennan wants to merge 23 commits into
Conversation
Release v8.0.0-next.0 Co-authored-by: RangerMauve <actions@users.noreply.github.com>
* chore: Delete typedoc and auto generated API docs * chore: remove typedoc from workflow and docs references --------- Co-authored-by: Mauve Signweaver <contact@mauve.moe>
Release v9.0.0-next.0 Co-authored-by: RangerMauve <actions@users.noreply.github.com>
…snapshot - PeerManager/SyncPeer: single registry keyed by device ID, connection-set per device (fixes reconnect map corruption), single sequenced capability writer (fixes capability clobber), cores only added for peers whose role permits sync - SyncProgress: collapses SyncState+NamespaceSyncState into one observation tree producing an immutable snapshot; back-fills peers on late-added cores - sync-rules.js: pure functions for sync mode, per-peer namespace enabling, and completion predicates shared by every consumer - CoreSyncState: renamed counts (have/toReceive/toSend, always from the local device's perspective) and channel states (closed/opening/open); guards update() continuation against teardown - SyncApi: new public state shape (syncMode + devices), waitForSync target 'all' replaces 'full', adds close() called first in project _close() - BLOCKED_ROLE.sync.auth is now 'allowed' so blocked peers learn they are blocked (replaces the preserve-auth workaround)
…wn cores Two completion-detection fixes found by the e2e suite: - Invalidate SyncProgress's derived-state cache when a peer's capability changes: capability decides whether a peer's blocks are counted, and there is no block-level event to bust the cache in that case (the structural fix for the bug 92ee66e patched and 66f1d82 reverted) - A peer's per-core state only assumes the peer wants blocks once there is evidence the peer knows the core exists (core existed at connect, open channel, pre-haves, or explicit want range). Late-discovered cores otherwise fabricate wants for peers that may never learn of the core (e.g. two invitees who have never met), blocking sync completion forever Also update tests to the new public sync state shape
- coreCount in sync progress snapshots only counts cores added to the core manager, not cores known solely from pre-have messages (e.g. a blocked peer's cores, which are never added) - test-e2e/utils.js: dirent.path was removed in Node 24, use parentPath - docs/concepts/sync.md: replace stub with an overview pointing at the sync-redesign doc
Namespaces now enable with a peer in stages: auth always; config and blobIndex only once auth sync with that peer has completed, the synced records have been indexed (multi-core-indexer idle), and the peer's capability re-read; data namespaces once the whole initial group completes. This closes the stale-role-record window: a device holding an out-of-date 'member' record for a removed device can no longer exchange anything beyond auth before it has every membership record the peer can relay — including the peer's own removal. Includes an e2e test where a device removed-while-offline relays its own block record; verified the test fails when the gate is disabled
Reorganize the sync tests around a declarative scenario helper (test-e2e/sync-scenario.js): named devices with roles, explicit connect/ disconnect topology, seeding with realistic data volumes, and assertions that read as the scenario under test. The monolithic test-e2e/sync.js is split into sync-data / sync-state / sync-lifecycle / sync-capability / sync-connection / sync-blobs, one concern per file; duplicates merged and role-update-sync-capability.js folded in. Coverage decisions and known gaps are documented in docs/development/sync-testing.md. Ports the bug repros from the test/sync-bug-repros branch as passing regression tests: project close mid-sync (no leaked listeners, no unhandled rejections), 3-peer completion, stall timeouts, transitive relay sync, offline/reconnect and mid-transfer resume, overlapping reconnect, three-peer block isolation, live unblock, blocked-device- learns-its-role. The leave-during-close race stays as a todo (known pre-existing bug outside sync). Fixes a real bug the overlap repro caught: channel state was clobbered to 'closed' by a stale connection's peer-remove while a second live connection existed; channel state is now derived across all of a device's connections. Removes the kSyncProgress/kPeerManager test seams — every assertion now goes through the public API. Deletes the CoreSyncState hypercore integration unit test (covered by e2e + pure deriveCoreState scenarios) and adds contiguous-boundary and core-knowledge unit tests.
… experiment/sync-refactor * origin/feat/hypercore11-migration: chore: fix jsdocs, remove unnecessary tests fix: want should behave the same as wantWord in sync state fix: ensure onProgress gets called with total cores at least once chore: document needed avail space as 1.5x largest core fix: account for missing dir when checking for migration fix: remote bitfield not being exchanged due to core length not being retrievable from peer.core chore: Bump hypercore dependencies chore: remove remaining API docs chore: remove typedoc (#1289) fix(ci): allow prerelease workflow to run on unmerged PRs (#1294) fix: Handle missing device id for version due to sync issues (#1288)
Thread the syncThrottleMs test seam (manager → project → SyncApi) so timing-sensitive tests can remove the 200ms state throttle, and run the stale-role-record test under the same adversarial conditions that make the bug deterministic in the old implementation (no throttle + 150 auth records keeping the indexer busy): the auth gate holds because it explicitly awaits indexing before re-reading capability.
Two HIGH findings from an adversarial two-agent review, both fixed with
regression tests:
- Cores known only from pre-have messages (e.g. a removed member's cores,
which we deliberately never add) were counted in sync progress, so
completion could block forever on transfers that will never happen.
Snapshots now only count cores actually added to the core manager.
- Completion predicates skipped namespaces blocked by a peer's placeholder
(not-yet-read) capability, so waitForSync('initial') could resolve after
auth alone, before config synced. Completion of non-auth namespaces now
requires the peer's auth gate to have opened.
Also: restore adding LEFT members' cores (their data remains project data
and must keep propagating — only BLOCKED/NO_ROLE are excluded); loop the
auth gate until the capability read is stable against records arriving
mid-gate, and retry if indexing fails; re-point the haves bitfield to a
surviving connection when an overlapped connection drops; emit a final
sync-state on close() so pending waiters settle; compare full document
content in assertDocsConverged; only treat not-found as 'not received' in
assertNeverReceived; cover blob transfers restarting the autostop timer.
The design/justification content (docs/development/sync-redesign.md) moves to the proposal PR (#1304), which is its natural home alongside review discussion; the bugs it addresses are evidenced with failing tests in #1303. The permanent docs keep what stays true regardless of the proposal's fate: the sync concepts overview and the test-suite structure.
This was referenced Jul 13, 2026
Product decision: blocking revokes what a device may receive (everything except auth, which carries its block record), not the project's claim on the data it contributed before the block. Only NO_ROLE devices' cores are skipped when adding a peer's cores. Replaces the blocked-cores-not-added test with one proving pre-block data reaches a device that joins after the block and never meets the blocked device. Review finding C2 is withdrawn accordingly (see PR #1303).
Project-wide totals (progress counts, completion, syncing-device count) derived from the per-device getState() output. Lives on a dependency-free subpath so frontends can import it without bundling backend code, keeping the definition of the aggregates in one place.
Closes the two tracked gaps in docs/development/sync-testing.md: rapid websocket reconnects against a real @comapeo/cloud server (the path where overlapping connections from one device occur in production), and blob originals not relaying through a non-archive intermediate — decided to be working as designed, originals move only on direct contact with the authoring device.
Summing the per-device counts over-counts: a block that three peers all hold shows as 300 toReceive when only 100 blocks are missing. The snapshot already computes union counts (each unique block once) in the same bitfield walk, so surface them as top-level initial/data totals — they shrink as blocks move and hit zero exactly at completion, which makes them the right progress denominators. aggregateSyncState() now reports these instead of per-device sums.
typedoc is gone, and JSDoc alone doesn't carry the semantics downstream implementers need for a breaking change: docs/api/sync.md documents $sync methods, events, the state shape (count meanings, union totals, per-device semantics) and the sync-state.js subpath; the old->new mapping fills the migration guide stub.
…peers Direct coverage for the disconnect-before-complete question: three devices mid-way through a 600-block transfer, the source disconnects, and the remaining pair must converge on the union of their partial copies and reach completion. Also pins why peer bitfield observation patches instance methods (hypercore dispatches wire messages by property lookup on the peer, so a Proxy wrapper would never see the calls).
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.
Note
Tentative / experimental. This PR proposes a redesign of
src/sync/. It is a breaking change to theproject.$syncAPI, so comapeo-mobile and comapeo-cloud need migrating before it can land. It exists to be evaluated and discussed. This description is the source of truth for the design (it replaces an in-repo design doc).Why
Sync is the hardest part of this codebase to maintain: we have repeatedly shipped bugs around completion detection ("is sync done?"), progress reporting, and connected-device counts. #1303 is the complete catalogue of known findings in the current implementation, with failing reproductions for the deterministic ones — its CI is red by design. All bug IDs in this description (A1, C1, D1, G1, …) refer to that catalogue.
The recurring bugs are not one-off mistakes; they trace to four structural problems:
isSynced,isInitiallySyncedWithPeer,getSyncStatus,arePresyncNamespacesSynced), each with different edge cases (B1, B3, F1).SyncApihad noclose(), so project close/leave tore down cores underneath live sync listeners and timers (A1, A3/A4, P0.13).The design
Three principles:
sync-rules.js): the sync-mode truth table, the per-peer namespace-enabling rules, and the completion predicates are plain functions over(intent, capability, snapshot), unit-tested exhaustively. Every consumer — public state,waitForSync, the invite flow's initial-sync wait, autostop — derives from the same snapshot with the same predicates, so completion definitions cannot disagree (fixes B3, F1 structurally).NO_ROLEuntil known.BLOCKED_ROLE.sync.authis now'allowed'(like LEFT), so a blocked device still receives the record telling it it was blocked — replacing the fragile preserve-auth workaround that C1 defeated. Capability changes explicitly invalidate the derived-state cache (the bug that 92ee66e patched and 66f1d82 reverted).The auth gate (new behavior)
Namespaces now enable with a peer in stages:
This closes G1 (the stale-role leak): a device holding an out-of-date "member" record for a removed device previously started sending it config immediately on connect, before syncing the auth records that would reveal the removal. It also fixes the subtler variant where the capability check raced the indexing of just-downloaded role records. The gate explicitly awaits
indexer.idle()and loops until the capability read is stable against newly-arriving records.Completion semantics
"Is sync done?" reduces to: with every connected device, is there nothing left to send and nothing left to receive? The two subtleties are how "left to send/receive" is counted before transfers actually start, and who counts as a connected device. One rule for each:
A channel still opening (length handshake pending) blocks completion; a closed channel with nothing outstanding does not — so a peer that has data sync turned off doesn't block everyone else's completion or autostop.
Public API (breaking)
initial.isSyncEnabled/data.isSyncEnabled(top level)syncMode !== 'stopped'/syncMode === 'all'remoteDeviceSyncStatedevices— now a reliable connected-device list across reconnects…[d].<group>.want(they want from us)devices[d].<group>.toSend…[d].<group>.wanted(we want from them)devices[d].<group>.toReceivedevices[d].<group>.isCompletewaitForSync('full')waitForSync('all')Count names are now always from the local device's point of view (
have/toReceive/toSend) — the oldwant/wantedflipped meaning between the local entry and remote entries. Per-deviceisSyncEnabledmeans "replication channels are open with this device", i.e. both sides enabled it.SyncApigains an idempotentclose(), called first inproject._close().The top-level
initial/datatotals count each unique block once, however many devices hold or need it — they are not the sum of the per-device counts (which count a block once per device, and would over-report by up to the peer count). The union numbers come straight from the same bitfield walk that produces everything else; they shrink as blocks move and reach 0 exactly at completion, making them the right "progress toward done" denominators. For a one-call summary,aggregateSyncState()on the new dependency-free@comapeo/core/sync-state.jssubpath export combines these totals with completion and device counts, without pulling backend code into a frontend bundle.The API surface is documented for downstream implementers in
docs/api/sync.md, with the old→new mapping indocs/guides/migrating.md.Internal vocabulary: the three confusable tri-states are now visibly distinct —
capability(allowed/blocked: may we sync?),channel(closed/opening/open: is the session up?), and completion (booleans from predicates, never stored as an enum). "presync"/"full" are retired forinitial/all.Bugs addressed (IDs from the #1303 catalogue)
SyncApi.close()tears down listeners, timers, throttles, websockets; guarded async continuationsproject._close()and its continuations survive cores closing under it; the pre-existing datatype/indexer leave‖close race remains (tracked as atodotest)BLOCKED_ROLE.sync.auth = 'allowed'removes the workaround C1 defeatedSyncPeerwith a connection set; per-connection channel accounting inCoreSyncStateisSyncEnabledderives from live channels, never a half-seeded merge (E2);devicessemantics are documented and per-group flags distinguish auth-only peers (E3); role transitions flow through the single capability writer + cache invalidation (E4)(A0 and F2 were already fixed on
mainbefore this branch.)Unchanged (wire compatibility)
The protocol is untouched: extension messages, pre-have broadcasting, blob download intents, and the bitfield-walk derivation math all stay as they were. The staged gate only changes when we replicate namespaces — old versions just see channels open slightly later.
cross-version-sync.js(syncing with published @comapeo/core 2.0.1) passes.Testing
The sync tests were rebuilt around a declarative scenario helper (
test-e2e/sync-scenario.js): named devices with roles, explicit dial-based topology, seeding at realistic volumes (hundreds of observations per device — several code paths only activate past one 32-bit bitfield word — and real multi-MB photo fixtures), and assertions that state intent (assertDocsConvergedcompares full document content,assertNeverReceivedproves non-delivery,recordStateInvariantproves "X never happened"). One concern per file: data / state / lifecycle / capability / connection / blobs. The bug repros from #1303 are ported as passing regression tests. The sync module exposes no test seams — every e2e assertion goes through the public API. Seedocs/development/sync-testing.mdfor the structure and coverage decisions.Two scenarios cover paths beyond local discovery: rapid websocket reconnect against a real @comapeo/cloud server (the path where overlapping connections from one device actually occur in production — the D1 territory), and blob-original non-relay through a non-archive intermediate (working as designed: originals require direct contact with the authoring device).
Full suite: ~250 unit + ~260 e2e tests pass (one
tododocumenting the pre-existing leave‖close datatype race).Review
The branch had a two-agent adversarial correctness review (core internals; integration + test-coverage regressions). Both HIGH findings it produced — pre-have-only cores blocking completion forever, and completion reading as vacuously true before a peer's role was known — are fixed in-branch with regression tests. Known accepted limitations (documented in code comments): the channel-open transition uses
core.update()resolution, which isn't strictly per-peer (covered in practice by connect-time pre-haves); blocks a peer acquires mid-session on a closed channel aren't visible until the channel opens.Resolved questions
aggregateSyncState()on the@comapeo/core/sync-state.jssubpath (see above), so frontends don't each define their own meaning for the totals.todotest here.