Skip to content

test: failing reproductions of sync bugs (evidence, do not merge) - #1303

Draft
gmaclennan wants to merge 6 commits into
feat/hypercore11-migrationfrom
test/sync-bug-evidence
Draft

test: failing reproductions of sync bugs (evidence, do not merge)#1303
gmaclennan wants to merge 6 commits into
feat/hypercore11-migrationfrom
test/sync-bug-evidence

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Jul 13, 2026

Copy link
Copy Markdown
Member

Important

Not for merging. This branch exists as evidence: each [BUG …] test below reproduces a bug in the current sync code and fails by design, so CI on this PR is red on purpose. The proposed fix is the sync refactor in #1304, which references these bugs by ID.

The sync subsystem has a history of intermittent, hard-to-diagnose bugs around sync completion, progress reporting, and device counts. To make those concrete, this branch adds black-box reproductions (plus a controllable transport for timing-dependent scenarios) against the current implementation. The bugs below stem from a code review by Claude Fable 5.0, and some are hard/impossible to reproduce reliably with failing tests.

The bugs are grouped by theme:

  • A* = leave/block/close lifecycle
  • B = completion detection
  • C = capability/permissions
  • D = connection lifecycle
  • E = progress and device-count reporting
  • F = state-derivation math
  • G = findings made later, during the refactor.
  • P0.x = test-coverage gaps.

1. Bugs with failing reproductions

ID Bug Repro
A1 SyncApi has no close(): its creator-core listeners, autostop timer, pending-discovery-key timers, and server websockets are never torn down when a project closes test-e2e/sync-lifecycle.js
A3/A4 Closing a project mid-sync leaves sync listeners live on closed cores (and on real-latency links the in-flight core.update({wait:true}) rejects unhandled) test-e2e/sync-lifecycle.js
P0.13 After a full project.close(), sync's peer-remove listeners remain on the creator core (the observable signal of A1/A3) test-e2e/sync-lifecycle.js
P0.12 leaveProject() racing project.close() rejects with Cannot await idle after closing (partially demonstrates A2 — see below) test-e2e/sync-lifecycle.js
B3 The internal initial-sync wait used by the invite flow never resolves for a peer whose presync namespaces are blocked — it only ever times out test-e2e/sync-completion.js
C1 After blocking a peer, any unrelated auth write re-caches that peer's capability without preserving the auth workaround — auth replication stops and the blocked peer can never learn it was blocked test-e2e/sync-capability.js
D1 Overlapping reconnect from the same device: the stale connection's teardown deletes the live connection's entries, so a connected device disappears from sync state and completion misbehaves test-e2e/sync-reconnect.js
F1 mutatingAddPeerState uses === where = was intended, so a peer's aggregated per-namespace status is order-dependent — feeding wrong isSyncEnabled reporting and the gate that enables data sync test/sync/sync-state-edge-cases.js
G1 A device holding a stale role record for a removed device starts replicating config immediately on connect (and data after presync) before syncing and indexing the auth records that would reveal the removal — new data leaks to a removed device. The capability re-read also races record indexing (syncThrottleMs: 0 + a realistic batch of auth records makes this deterministic) test-e2e/sync-capability.js

2. Confirmed in review, no deterministic repro

These were verified by reading the code paths but are either RTT-bounded races that don't reproduce on loopback, or design-level inconsistencies with no single failing assertion. This section is their record.

  • A2leaveProject()'s kClearData closes writer cores and purges/compacts other members' data while sync is still active with connected peers (a blanket sync-stop would be wrong: the leave flow needs auth to keep replicating to propagate the LEFT role). The exposed window is CoreSyncState listeners running derivation on closed cores during the post-leave auth-resync. P0.12 above demonstrates one symptom of this window.
  • B1waitForSync('initial') can resolve before late-discovered cores replicate: per-core peer state is seeded only for cores that existed when the peer connected, and late-added cores never back-fill known peers. The isSynced guard is satisfied by sibling cores, so addProject can return before auth/config is actually pulled. RTT-bounded, so it passes on loopback; the ported test documents the structural gap in comments.
  • B2getSyncStatus decides 'synced' from the namespace-global localState.want (a union across all peers) rather than per-peer counts, so enabling data sync with peer X can be held up by peer Y's in-flight transfer. Conservative in direction, but it consumes the same status-merge output as F1, so it is not purely cosmetic.
  • C3 — capability reads (getRole) are unsequenced: two in-flight reads can resolve out of order and cache a stale capability. Self-heals on the next throttled state event; low individual harm, but it compounds the C1 path.
  • D2 — the peer-disconnect handler's early-return (when no controller is found for the connection) skips disconnectPeer, leaving that peer's per-core state frozen forever.
  • E1 — reported device state mixes the throttled state snapshot with a live capability read, so a single emitted state can be internally inconsistent.
  • E2 — a group's isSyncEnabled can over-report true when one namespace in the group has no peer state yet (the aggregation skips it instead of counting it against the group).
  • E3 — NO_ROLE/LEFT peers (whose auth capability is 'allowed') appear in remoteDeviceSyncState, so Object.keys(...).length is not a reliable count of actively-syncing devices — the source of the recurring device-count over/under-reporting.
  • E4 — role transitions interact badly with the capability cache: a block can flip auth off via the C1 path, and a LEFT device can stay counted as syncing. Structurally the same root cause as C1/E3, listed separately because the user-visible symptom (wrong device counts during role changes) was reported independently.

3. Already fixed on main (kept as passing regression coverage)

  • A0leaveProject() rejects with Sync timeout even though the local leave already succeeded: the trailing propagation wait rejects on timeout instead of being best-effort. Confirmed in production; fixed on main. The repro here ([A0 — fixed on main] in test-e2e/sync-lifecycle.js) passes and stays as regression coverage.
  • F2 — scalar want(index) disagrees with have() and wantWord() at the contiguousLength boundary (> vs >=). No runtime impact (the scalar forms had no callers); fixed on main, covered by a passing unit test here.

4. Withdrawn after a product decision

  • C2 — "a blocked peer's config/data/blobIndex cores are still added (and downloaded) locally — the gate checks for NO_ROLE only." Originally judged a bug (fetching a blocked device's data from third parties looked unintended). This is the desired behavior: 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 — that data remains project data and must keep propagating, e.g. to a new device or archive that never met the blocked device.

5. Housekeeping (no behavior, no test)

  • D3 — dead #downloadingRanges field in peer-sync-controller.js, declared but never read or written.

Note

CI runs npm test, which fail-fasts at the first failing suite — so the red check shows the first repro (F1, in the unit tests) rather than all of them. Run the command below to see every repro fail locally.

Running

node --test test-e2e/sync-lifecycle.js test-e2e/sync-completion.js \
  test-e2e/sync-capability.js test-e2e/sync-reconnect.js \
  test/sync/sync-state-edge-cases.js

test-e2e/controllable-wire.js (added here) drives two real projects over a pausable/latency transport via the kProjectReplicate seam; the syncThrottleMs option is threaded through manager → project → sync so tests can remove the 200ms state throttle where determinism matters.

gmaclennan and others added 4 commits July 13, 2026 11:41
Failing reproductions (node:test `todo`) and coverage tests for the sync
review findings (docs/sync-review.md, docs/sync-plan.md), plus a
controllable-transport test helper (test-e2e/controllable-wire.js) that
drives two real projects over a latency / pause / abrupt-teardown wire via
the kProjectReplicate seam — letting us reproduce timing/ordering bugs as
deterministic, black-box tests.

- test/sync/sync-state-edge-cases.js: F1, F2 (todo) + deriveState
  blocked-peer exclusion and contiguousLength masking coverage
- test-e2e/sync-{lifecycle,completion,capability,reconnect}.js
- D1 and A2 converted to deterministic black-box repros
- exports aggregatePeerStateForTesting (test-only) from
  namespace-sync-state.js, mirroring deriveState/PeerState

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent limits

C1: rewrite as a reconnect-over-latency-wire scenario (block while offline,
reconnect over a slow wire) instead of the 3-peer unrelated-auth-write attempt.
Documents honestly that it does not deterministically fail: A serves the BLOCKED
block on the peer's request (one latency in) before A's capability re-evaluation
— delayed behind the 200ms SyncState throttle — can disable auth, so the peer
reliably learns. Kept as a best-effort regression guard for the structural fix.

B1: document that the premature-completion window is a first-contact,
sub-throttle micro-window that the kProjectReplicate wire cannot target (first
core-discovery runs over the invite/local-discovery RPC path). Kept as a
best-effort racy guard.

D1 and A2 (the coarse-grained ordering / full-stall bugs) DID convert to
deterministic black-box failing repros via test-e2e/controllable-wire.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a syncThrottleMs test-seam threaded Manager -> Project -> SyncApi (default
200ms; set to 0 for immediate SyncState re-evaluation), and rebuilds the C1
reproduction to fail deterministically:

  - 3 peers; A<->B over an open controllable wire, A<->C over a SLOW one.
  - Block B (the #handleRolesUpdate workaround preserves auth='allowed').
  - An unrelated auth write from coordinator C, delivered slowly, keeps A's auth
    want > 0 long enough that A observes the syncStatus synced->syncing->synced
    transition, firing #handleStateChange's non-preserving capability re-read,
    which overwrites auth to 'blocked' and disables auth replication to B.
  - Assert the invariant "auth stays enabled for a blocked peer" — fails today,
    passes once auth-preservation moves into #readAndCacheSyncCapability (or
    BLOCKED_ROLE.sync.auth='allowed'). Verified both directions; stable over 3 runs.

B1 stays a documented best-effort todo: its premature-completion window is a
first-contact event bundled into the invite/RPC path (not the kProjectReplicate
wire), and NamespaceSyncState aggregation masks a single late core, so neither
the e2e wire nor the unit seam can target it yet. Documented in docs/sync-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This branch exists as evidence: each [BUG …] test reproduces a bug in the
existing sync implementation and FAILS by design — see the PR description
for the bug list. The remaining tests are passing coverage of adjacent
behavior. Repros that were previously tagged 'todo' now fail hard so CI
demonstrates the bugs. Bugs since fixed on main (leaveProject timeout,
want/wantWord boundary) are kept as passing regression coverage. Adds a
new deterministic repro [BUG G1]: a stale role record lets config
replicate with a removed device before its removal record is indexed.
gmaclennan added a commit that referenced this pull request Jul 13, 2026
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.
A0 is the leaveProject 'Sync timeout' production bug (fixed on main); A2
is a distinct finding (kClearData purges cores under active sync) listed
in the PR description without a direct repro.
Decided (July 2026): 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. The core-adding this test flagged
as a bug is intended behavior. See the bug catalogue in PR #1303.
gmaclennan added a commit that referenced this pull request 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).
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