From 88646f314678a2741b047a5219885259ce8ae0d6 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 29 Apr 2026 18:56:39 +0100 Subject: [PATCH 1/2] 0.5.4: send handshake on replacement transports in _createPeer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion fix to v0.5.3. When the dual-dial dedup or stale-prior swap path in _createPeer replaced an existing transport, the new transport was registered in existingPeer.transports but no handshake was sent on it — _addPeer (which sends the handshake) is only called for brand-new peers, not transport replacements. The remote (sym-swift) saw the new connection reach .ready, sent its own handshake, and waited for ours. Ours never arrived. ~10 seconds later the heartbeat tick fired `ping` on every transport, the remote saw `ping` as the first frame, and disconnected with "[SYM] session: expected handshake, got ping" — protocol violation. Net result: every reconnect after a dedup-replace was killed within 10 seconds by the protocol-violation trip-wire, producing the flap loop visible on Mac Catalyst MeloMove. Fix: extracted handshake-build into _buildHandshake() helper; the existing-peer branch in _createPeer now sends the handshake on every newly-registered transport. Idempotent — if the remote already sent its handshake, it processes both fine. Verified end-to-end on Mac Catalyst MeloMove ↔ claude-code-mac on the same Mac. Connection stays stable, peers persist in the UI, CMBs flow continuously without the 10s drop. 150/150 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ lib/node.js | 34 ++++++++++++++++++++++++++++------ package.json | 2 +- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 542df1d..0dce360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ > **Note:** Versions 0.3.26 – 0.3.55 were released as git tags without changelog entries. Changelog resumes at 0.3.56 below. +## 0.5.4 + +### Fixed + +- **Replacement transports never received a handshake; remote rejected the + next heartbeat-`ping` as a protocol violation.** Companion fix to v0.5.3. + When the dual-dial dedup or stale-prior swap path in `_createPeer` replaced + an existing transport, the new transport was registered in + `existingPeer.transports` but no handshake was sent on it — `_addPeer` + (which sends the handshake) is only called for brand-new peers, not + transport replacements. The remote (sym-swift) saw the new connection + reach `.ready`, sent its own handshake, and waited for ours. Ours never + arrived. ~10 seconds later the heartbeat tick fired `ping` on every + transport, the remote saw `ping` as the first frame, and disconnected + with `[SYM] session: expected handshake, got ping` — protocol violation. + Net result: a flap loop where every reconnect was killed within 10s by + the protocol-violation trip-wire. + + Fix: extracted handshake-build into `_buildHandshake()` helper; the + existing-peer branch in `_createPeer` now sends the handshake on every + newly-registered transport. Idempotent — if the remote already sent + its handshake, it processes both fine. + + Verified end-to-end on Mac Catalyst MeloMove ↔ claude-code-mac (Node) + on the same Mac. Connection stays stable, peers persist in the UI, + CMBs flow continuously without the periodic 10s drop. + ## 0.5.3 ### Fixed diff --git a/lib/node.js b/lib/node.js index 75b68d6..261d1a3 100644 --- a/lib/node.js +++ b/lib/node.js @@ -1235,6 +1235,17 @@ class SymNode extends EventEmitter { existingPeer.transports.set(source, transport); this._log(`Transport added for ${peerName}: ${source} (${existingPeer.transports.size} transports)`); + // Send handshake on the new transport. _addPeer is only called for + // brand-new peers; once a peer exists, additional/replacement + // transports route through this branch and would otherwise sit in + // the dict with no handshake ever sent on them. The next heartbeat + // tick fires `ping` on every transport, the remote sees `ping` as + // the first frame, and rejects the connection ("expected handshake, + // got ping" — a sym-swift-side disconnect on protocol violation). + // Idempotent: if the remote also sent a handshake from its side, + // it processes both fine. + try { transport.send(this._buildHandshake()); } catch {} + // Identity-aware close handler. A previously-closed transport for the // same source has its own pending close handler that, if it fires after // we replace the entry here, would `transports.delete(source)` away @@ -1308,11 +1319,15 @@ class SymNode extends EventEmitter { return null; } - _addPeer(peer) { - this._peers.set(peer.peerId, peer); - - // Handshake — per MMP Section 5.2 + §5.8 optional `group` field - peer.transport.send({ + /// Builds a handshake frame addressed to the given peer. Per MMP §5.2 + + /// §5.8 optional `group` field. Extracted into a helper so the dedup + /// replace paths in `_createPeer` can also send a handshake on the new + /// transport — without it, a replacement transport sits in the dict with + /// no handshake ever sent, and the next heartbeat-tick `ping` becomes the + /// first frame the remote sees, tripping its "first frame must be + /// handshake" check. + _buildHandshake() { + return { type: 'handshake', nodeId: this._identity.nodeId, name: this.name, @@ -1322,7 +1337,14 @@ class SymNode extends EventEmitter { publicKey: this._identity.publicKey, e2ePublicKey: this._e2eKeyPair.publicKey.toString('base64'), lifecycleRole: this._lifecycleRole, - }); + }; + } + + _addPeer(peer) { + this._peers.set(peer.peerId, peer); + + // Handshake — per MMP Section 5.2 + §5.8 optional `group` field + peer.transport.send(this._buildHandshake()); // MMP v0.2.2: no state-sync — hidden states never cross the wire under // SVAF (Xu, 2026, arXiv:2604.03955, §3.4). Cognitive bootstrap to a diff --git a/package.json b/package.json index af0f76f..8b09975 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sym-bot/sym", - "version": "0.5.3", + "version": "0.5.4", "description": "Infrastructure and protocol for multi-agent collective intelligence", "main": "lib/node.js", "bin": { From 4a4a3f407f52da9ef0cba9be803865addfef9a50 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 29 Apr 2026 19:02:20 +0100 Subject: [PATCH 2/2] ci: re-run after Node 18 flake