Skip to content

fix(sync): don't use core.update() for peer 'started' - use peer's Synchronize instead#1306

Open
gmaclennan wants to merge 2 commits into
feat/hypercore11-migrationfrom
fix/peer-started-status
Open

fix(sync): don't use core.update() for peer 'started' - use peer's Synchronize instead#1306
gmaclennan wants to merge 2 commits into
feat/hypercore11-migrationfrom
fix/peer-started-status

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Jul 15, 2026

Copy link
Copy Markdown
Member

Problem

CoreSyncState marked a peer's per-core status 'started' when core.update({ wait: true }) resolved. That call answers a core-global question — "am I up to date with the swarm?" — not "has this peer completed its length handshake". Three ways the two diverge (all verified against hypercore 11.30.2 source):

  1. Writable cores short-circuit: update() resolves immediately for a writer (hypercore/index.js:733), so peers on our own writer cores were marked 'started' before they had sent anything.
  2. Any upgrade resolves all waiters: every concurrent update() attaches to one shared upgrade request, resolved as soon as any peer advances the core's length (replicator.js onupgrade()_resolveUpgradeRequest). On an actively-transferring core, a newly-connected peer was marked 'started' the moment the next block batch landed — from someone else.
  3. The quorum cap: _checkUpgradeIfAvailable() samples at most MAX_PEERS_UPGRADE = 3 peers, so with 4+ connected peers and any ambient traffic, the shared request resolves without ever consulting a new peer's handshake.

A prematurely-'started' peer reads as "has nothing, wants everything". Where we hold no blocks for that core, that is indistinguishable from "nothing left to sync", so isSynced() / waitForSync() / autostop could report or act on completion before hearing from the peer. Field deployments routinely have 4+ concurrent peers and in-flight transfers, so modes 2 and 3 are everyday occurrences, not edge cases.

Fix

Flip status to 'started' when the peer's own first Synchronize wire message is processed — hypercore records exactly this per-peer fact as peer.remoteSynced, set synchronously at the top of peer.onsync(). We intercept by shadowing onsync on the peer instance, the same interception already used for onbitfield/onrange (hypercore dispatches wire messages by property lookup on the channel's userData).

The fallback, and why it is safe. One real case exists where the first Synchronize never arrives on a live channel: a channel close/open crossing on the same protomux — both sides disable and re-enable a namespace around the same time, which routinely happens during the invite flow's capability churn. One side ends up with a fresh peer holding no baseline while the remote believes its old session is still live, so it never re-sends its state, and hypercore does not recover on its own. There is no reliable in-band repair (hypercore replies to an incoming sync only when it has something newer; replies to wants only with bitfield pages it has; and the duplicate channel open is dropped by protomux before hypercore sees it — verified empirically). Forcing a channel renegotiation from the observation layer was tried and reverted: it destabilizes the layers that own the channel lifecycle (the re-replicate can silently fail to establish a channel, stranding the peer — this hung CI). So after 5s the peer is treated as 'started' with its channel-level state unknown.

This is not fabricated sync state, because the counts this status gates don't depend on the channel handshake alone: a peer's blocks are counted from its pre-have bitfields (broadcast over the project creator core at connect and on append) merged with the channel-level bitfield (PeerState#haveWord). A peer that has data is counted correctly even when its channel handshake was lost to a crossing; the status flip just stops gating counts that are already correct. A Synchronize arriving after the fallback still updates state. The root fix is upstream: protomux channel renegotiation needs a close reason / channel generation (hypercore's own Peer#onclose has a TODO to this effect).

This also removes an unhandled-rejection hazard: the old .then() had no rejection handler, and hypercore rejects pending update() calls when a core closes.

src/types.ts gains remoteSynced, removed and onsync on the HypercorePeer subset type.

Tests

New tests hold a peer in the "channel open, Synchronize not yet processed" state via a holdSynchronize() helper (queues the intercepted message; deterministic, verified stable across repeated runs). Each of the three failure modes has a regression test verified to fail against the old implementation:

  • writable core (mode 1): status must stay 'starting' while the peer's Synchronize is withheld;
  • upgrade from another peer (mode 2): a writer append/download must not flip a new, still-silent peer to 'started';
  • quorum cap (mode 3): with three synced peers and ambient traffic from a fifth, a still-silent fourth peer must stay 'starting';
  • fallback: a peer whose Synchronize never arrives becomes 'started' after the fallback rather than blocking forever (asserted to have happened without a Synchronize);
  • attach-late: a defensive invariant — a peer already synced when the core is attached becomes 'started' immediately. Not reachable in production wiring (cores attach synchronously on 'add-core', before a channel handshake can complete; verified empirically across the connection-heavy e2e suites), but nothing in CoreSyncState enforces attach-before-handshake, so the class handles it.

Verified on Node 18 and 22: full unit suite, tsc, eslint, prettier, and the full e2e suite including the invite-flow crossing cases (test-e2e/members.js "remove member from project, add them back", test-e2e/project-leave.js "Member can join project again after leaving").

Follow-ups

  • The same pattern exists in the rewritten #onPeerAdd on experiment/sync-refactor (refactor: redesign sync around per-device peers, pure rules, and a single state snapshot #1304), where it drives the opening/open channel states — it needs the equivalent port (per-peer Synchronize gate + fallback), otherwise its openingChannels > 0 completion gate hangs the same way in remove/re-add flows.
  • Report the renegotiation crossing upstream: hypercore's Peer#onclose has a TODO acknowledging the renegotiation is messy ("add a CLOSE_REASON to mux so we can make this cleaner"); a mux-level close reason / channel generation would fix this at the root, and would let the 5s fallback be deleted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CzqT9t19AV5voYFM2vYV8h

@gmaclennan gmaclennan changed the title fix(sync): gate peer 'started' status on the peer's own Synchronize, not core.update() fix(sync): don't use core.update() for peer 'started' - use peer's Synchronize instead Jul 15, 2026
…the peer's handshake

CoreSyncState marks a peer's per-core status 'started' when
core.update({ wait: true }) resolves. That call answers a core-global
question - "am I up to date with the swarm?" - not "has this peer
completed its length handshake", and the two diverge three ways:

1. update() resolves immediately for writable cores, so peers on our own
   writer cores are marked 'started' before they have sent anything.
2. All concurrent update() calls share one upgrade request, resolved as
   soon as any peer advances the core's length - so on an actively
   transferring core a newly-connected peer is marked 'started' the
   moment the next block batch lands, from someone else.
3. The upgrade check samples at most MAX_PEERS_UPGRADE (3) peers, so
   with 4+ connected peers and any ambient traffic the request resolves
   without ever consulting a new peer's handshake.

A prematurely-'started' peer reads as "has nothing, wants everything";
where we hold no blocks that is indistinguishable from "nothing left to
sync", so isSynced()/waitForSync()/autostop can act on completion before
hearing from the peer. Field deployments routinely run 4+ concurrent
peers with transfers in flight, so modes 2 and 3 are everyday
occurrences - and none of them were covered by tests before.

The new tests hold a peer in the "channel open, Synchronize not yet
processed" state via a holdSynchronize() helper and assert the peer must
still be 'starting'; one test per mode. All three fail on the current
implementation. (src/types.ts additions are type-only, needed for the
tests to typecheck.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqT9t19AV5voYFM2vYV8h
@gmaclennan
gmaclennan force-pushed the fix/peer-started-status branch 2 times, most recently from 605600b to 8e8e23d Compare July 15, 2026 11:52
…not core.update()

Flip status to 'started' when the peer's own first Synchronize wire
message is processed - hypercore records exactly this per-peer fact as
peer.remoteSynced, set synchronously at the top of peer.onsync(). We
intercept by shadowing onsync on the peer instance, the same
interception already used for onbitfield/onrange (hypercore dispatches
wire messages by property lookup on the channel's userData). This fixes
all three failure modes of the update()-based signal covered by the
previous commit's tests. It also removes an unhandled-rejection hazard:
the old .then() had no rejection handler, and hypercore rejects pending
update() calls when a core closes.

One real case exists where the first Synchronize never arrives on a live
channel: a close/open crossing on the same protomux - both sides disable
and re-enable a namespace around the same time, which routinely happens
during the invite flow's capability churn. One side ends up with a fresh
peer holding no baseline while the remote believes its old session is
still live, so it never re-sends its state, and hypercore does not
recover on its own. There is no reliable in-band repair: no message we
can send guarantees a Synchronize in reply, the duplicate channel open
is dropped by protomux before hypercore sees it, and forcing a channel
renegotiation from this observation layer destabilizes the layers that
own the channel lifecycle (tried; it could strand a peer with no channel
at all). So after 5s the peer is treated as started with its
channel-level state unknown. This does not fabricate sync state: a
peer's blocks are counted from its pre-have bitfields (broadcast over
the project creator core at connect and on append) merged with the
channel-level bitfield, so a peer that has data is still counted
correctly - the fallback stops gating counts that are already correct,
and a Synchronize arriving later still updates state. The root fix is
upstream: protomux channel renegotiation needs a close reason / channel
generation (hypercore's own Peer#onclose has a TODO to this effect).

Peers whose Synchronize was processed before the core was attached are
detected via remoteSynced and marked started immediately - defensive
only: production wiring attaches cores synchronously on 'add-core',
before a channel handshake can complete, but nothing in this class
enforces that.

Adds tests for the fallback (a silent session must not block sync state
forever, asserted to have resolved without a Synchronize) and for the
attach-late invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqT9t19AV5voYFM2vYV8h
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