Skip to content

refactor: redesign sync around per-device peers, pure rules, and a single state snapshot#1304

Draft
gmaclennan wants to merge 23 commits into
feat/hypercore11-migrationfrom
experiment/sync-refactor
Draft

refactor: redesign sync around per-device peers, pure rules, and a single state snapshot#1304
gmaclennan wants to merge 23 commits into
feat/hypercore11-migrationfrom
experiment/sync-refactor

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Jul 13, 2026

Copy link
Copy Markdown
Member

Note

Tentative / experimental. This PR proposes a redesign of src/sync/. It is a breaking change to the project.$sync API, 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:

  1. Peer identity was split across three hand-synced maps (per-connection, per-device, per-core). An overlapping reconnect corrupted them, making a connected device disappear from sync state (D1).
  2. Replication decisions were a feedback loop through the observation layer: what to replicate with a peer was decided by listening to throttled events about how replication was going. That interleave is where the capability-clobber bug lived (C1).
  3. Completion had four disagreeing definitions (isSynced, isInitiallySyncedWithPeer, getSyncStatus, arePresyncNamespacesSynced), each with different edge cases (B1, B3, F1).
  4. No lifecycle owner: SyncApi had no close(), so project close/leave tore down cores underneath live sync listeners and timers (A1, A3/A4, P0.13).

The design

SyncApi                  facade: intent (start/stop/background), autostop,
 │                       waitForSync, public state, close()
 ├── PeerManager         THE single registry of connected devices
 │    └── SyncPeer (×N)  one per DEVICE (not per connection): its connections,
 │                       its role capability, which namespaces replicate with it
 └── SyncProgress        observation: one CoreSyncState per core, aggregated
      └── CoreSyncState  into one immutable snapshot (throttled)

Three principles:

  • One source of truth for "who is connected". Peers are keyed by device ID and own a set of connections; a device disconnects only when its last connection closes. A stale connection's teardown can never remove a live connection's state (fixes D1).
  • Decisions are pure functions of a snapshot (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).
  • Capability has one writer, no pseudo-states. A peer's per-namespace capability is read from its role in exactly one sequenced code path, seeded from NO_ROLE until known. BLOCKED_ROLE.sync.auth is 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:

  1. auth — always (it carries roles and core ownership).
  2. config, blobIndex — only once auth sync with that peer is complete and indexed and the capability re-read.
  3. data, blob — additionally once the whole initial group completes with that peer.

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:

  • What counts as pending: evidence-based wants. Before a peer starts pulling data, its bitfields tell us nothing — yet progress reporting must already show "this peer needs 200 blocks". So a connected peer is assumed to want every block it doesn't have. That assumption invents a transfer, and an invented transfer is a completion liability: if the peer has no way to even learn that a core exists (e.g. the cores of a device that was never a member, which we know about only through pre-haves), the invented transfer can never happen, and completion would wait for it forever. The rule: only apply the assumption when the peer can know about the core — it existed when the peer connected, or we have seen proof it knows (an open replication channel for it, pre-haves from it, an explicit want-range). Err on the "assume" side and completion hangs on phantom transfers; err on the "don't assume" side and completion reports done while real data is still pending — bug B1 was the old code doing the latter for cores discovered after a peer connected.
  • Who counts: the connected-peer registry, after the auth gate. Completion is evaluated only over currently-connected devices, so a peer that disconnects mid-sync can never wedge the remaining peers: whatever it still had for us, or still needed from us, is deliberately forgotten on disconnect (there is currently no way to persist "a device out there has data we lack"). In the other direction, a connected peer always blocks completion until the channel handshake and its auth gate have finished — before that we don't know what the peer has, or even whether its role permits syncing, so declaring "complete" would recreate the old "initial sync reported complete before config synced" class of bug.

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)

$sync.getState() / 'sync-state'  {
  syncMode: 'stopped' | 'initial' | 'all',
  initial: { have, toReceive, toSend },   // totals: each unique block once
  data:    { have, toReceive, toSend },
  devices: {
    [deviceId]: {
      initial: { isSyncEnabled, isComplete, toReceive, toSend },
      data:    { isSyncEnabled, isComplete, toReceive, toSend },
    }
  }
}
Old New
initial.isSyncEnabled / data.isSyncEnabled (top level) syncMode !== 'stopped' / syncMode === 'all'
remoteDeviceSyncState devices — 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>.toReceive
(none) devices[d].<group>.isComplete
waitForSync('full') waitForSync('all')

Count names are now always from the local device's point of view (have / toReceive / toSend) — the old want/wanted flipped meaning between the local entry and remote entries. Per-device isSyncEnabled means "replication channels are open with this device", i.e. both sides enabled it. SyncApi gains an idempotent close(), called first in project._close().

The top-level initial/data totals 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.js subpath 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 in docs/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 for initial/all.

Bugs addressed (IDs from the #1303 catalogue)

ID Fix
A1, A3/A4, P0.13 SyncApi.close() tears down listeners, timers, throttles, websockets; guarded async continuations
A2, P0.12 partially: sync now tears down first in project._close() and its continuations survive cores closing under it; the pre-existing datatype/indexer leave‖close race remains (tracked as a todo test)
B1 registry-gated completion + evidence-based wants + late-core back-fill
B2 the data-enable gate is per-peer, via the same shared predicate as every other completion question
B3 completion predicates skip genuinely-blocked namespaces, so invite-as-blocked resolves
C1 single sequenced capability writer; BLOCKED_ROLE.sync.auth = 'allowed' removes the workaround C1 defeated
C2 withdrawn as a bug (see #1303 §4): a blocked device's pre-block data deliberately keeps propagating — blocking revokes what the device may receive, not the project's claim on data it contributed. Only NO_ROLE (never-a-member) devices' cores are skipped
C3 capability reads are sequenced with a monotonic read id — a stale read can never overwrite a newer one
D1 per-device SyncPeer with a connection set; per-connection channel accounting in CoreSyncState
D2 disconnect is keyed on the single peer registry and idempotent — no early-return path skips cleanup
D3 gone (the class it lived in no longer exists)
E1–E4 public state is derived in one pass from one snapshot (E1); per-device group isSyncEnabled derives from live channels, never a half-seeded merge (E2); devices semantics are documented and per-group flags distinguish auth-only peers (E3); role transitions flow through the single capability writer + cache invalidation (E4)
F1 status enums are never merged; group completion is a predicate
G1 the auth gate (above)

(A0 and F2 were already fixed on main before 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 (assertDocsConverged compares full document content, assertNeverReceived proves non-delivery, recordStateInvariant proves "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. See docs/development/sync-testing.md for 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 todo documenting 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

  • Public state shape: per-device state plus top-level per-group totals with union semantics (each unique block once — not derivable client-side, so no duplicated definition); project-wide aggregation lives in this package as aggregateSyncState() on the @comapeo/core/sync-state.js subpath (see above), so frontends don't each define their own meaning for the totals.
  • BLOCKED devices' pre-block data: keeps propagating, same as LEFT. The risk blocking addresses is the device reading project data, not the data it wrote — which may be exactly the valuable data the project needs (and any bad edits are recoverable from the immutable log). Only NO_ROLE devices' cores are skipped.
  • The leave‖close datatype race (outside sync): tracked with fix suggestions on project.close() race condition #407; stays a todo test here.

optic-release-automation Bot and others added 17 commits June 29, 2026 13:40
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.
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).
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