test: failing reproductions of sync bugs (evidence, do not merge) - #1303
Draft
gmaclennan wants to merge 6 commits into
Draft
test: failing reproductions of sync bugs (evidence, do not merge)#1303gmaclennan wants to merge 6 commits into
gmaclennan wants to merge 6 commits into
Conversation
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).
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.
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:
1. Bugs with failing reproductions
SyncApihas noclose(): its creator-core listeners, autostop timer, pending-discovery-key timers, and server websockets are never torn down when a project closestest-e2e/sync-lifecycle.jscore.update({wait:true})rejects unhandled)test-e2e/sync-lifecycle.jsproject.close(), sync'speer-removelisteners remain on the creator core (the observable signal of A1/A3)test-e2e/sync-lifecycle.jsleaveProject()racingproject.close()rejects withCannot await idle after closing(partially demonstrates A2 — see below)test-e2e/sync-lifecycle.jstest-e2e/sync-completion.jstest-e2e/sync-capability.jstest-e2e/sync-reconnect.jsmutatingAddPeerStateuses===where=was intended, so a peer's aggregated per-namespace status is order-dependent — feeding wrongisSyncEnabledreporting and the gate that enables data synctest/sync/sync-state-edge-cases.jssyncThrottleMs: 0+ a realistic batch of auth records makes this deterministic)test-e2e/sync-capability.js2. 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.
leaveProject()'skClearDatacloses 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.waitForSync('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. TheisSyncedguard is satisfied by sibling cores, soaddProjectcan return before auth/config is actually pulled. RTT-bounded, so it passes on loopback; the ported test documents the structural gap in comments.getSyncStatusdecides'synced'from the namespace-globallocalState.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.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.disconnectPeer, leaving that peer's per-core state frozen forever.isSyncEnabledcan over-reporttruewhen one namespace in the group has no peer state yet (the aggregation skips it instead of counting it against the group).'allowed') appear inremoteDeviceSyncState, soObject.keys(...).lengthis not a reliable count of actively-syncing devices — the source of the recurring device-count over/under-reporting.3. Already fixed on
main(kept as passing regression coverage)leaveProject()rejects withSync timeouteven though the local leave already succeeded: the trailing propagation wait rejects on timeout instead of being best-effort. Confirmed in production; fixed onmain. The repro here ([A0 — fixed on main]intest-e2e/sync-lifecycle.js) passes and stays as regression coverage.want(index)disagrees withhave()andwantWord()at thecontiguousLengthboundary (>vs>=). No runtime impact (the scalar forms had no callers); fixed onmain, covered by a passing unit test here.4. Withdrawn after a product decision
NO_ROLEonly." 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)
#downloadingRangesfield inpeer-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
test-e2e/controllable-wire.js(added here) drives two real projects over a pausable/latency transport via thekProjectReplicateseam; thesyncThrottleMsoption is threaded through manager → project → sync so tests can remove the 200ms state throttle where determinism matters.