fix(sync): don't use core.update() for peer 'started' - use peer's Synchronize instead#1306
Open
gmaclennan wants to merge 2 commits into
Open
fix(sync): don't use core.update() for peer 'started' - use peer's Synchronize instead#1306gmaclennan wants to merge 2 commits into
core.update() for peer 'started' - use peer's Synchronize instead#1306gmaclennan wants to merge 2 commits into
Conversation
core.update() for peer 'started' - use peer's Synchronize instead
…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
force-pushed
the
fix/peer-started-status
branch
2 times, most recently
from
July 15, 2026 11:52
605600b to
8e8e23d
Compare
…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
gmaclennan
force-pushed
the
fix/peer-started-status
branch
from
July 15, 2026 12:33
8e8e23d to
35d1ca7
Compare
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.
Problem
CoreSyncStatemarked a peer's per-core status'started'whencore.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):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.update()attaches to one shared upgrade request, resolved as soon as any peer advances the core's length (replicator.jsonupgrade()→_resolveUpgradeRequest). On an actively-transferring core, a newly-connected peer was marked'started'the moment the next block batch landed — from someone else._checkUpgradeIfAvailable()samples at mostMAX_PEERS_UPGRADE = 3peers, 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", soisSynced()/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 firstSynchronizewire message is processed — hypercore records exactly this per-peer fact aspeer.remoteSynced, set synchronously at the top ofpeer.onsync(). We intercept by shadowingonsyncon the peer instance, the same interception already used foronbitfield/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
Synchronizenever 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. ASynchronizearriving after the fallback still updates state. The root fix is upstream: protomux channel renegotiation needs a close reason / channel generation (hypercore's ownPeer#onclosehas a TODO to this effect).This also removes an unhandled-rejection hazard: the old
.then()had no rejection handler, and hypercore rejects pendingupdate()calls when a core closes.src/types.tsgainsremoteSynced,removedandonsyncon theHypercorePeersubset type.Tests
New tests hold a peer in the "channel open,
Synchronizenot yet processed" state via aholdSynchronize()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:'starting'while the peer'sSynchronizeis withheld;'started';'starting';Synchronizenever arrives becomes'started'after the fallback rather than blocking forever (asserted to have happened without aSynchronize);'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 inCoreSyncStateenforces 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
#onPeerAddonexperiment/sync-refactor(refactor: redesign sync around per-device peers, pure rules, and a single state snapshot #1304), where it drives theopening/openchannel states — it needs the equivalent port (per-peerSynchronizegate + fallback), otherwise itsopeningChannels > 0completion gate hangs the same way in remove/re-add flows.Peer#onclosehas 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