diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-16 10:41:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-16 10:41:17 +0200 |
| commit | 2167b7cd7ec006ba3f736bc18970f86f15d88a2a (patch) | |
| tree | 5d2426b4fb4b32f6a00e8d3014d3675595e39df6 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 88f4ade0ee1e1a2b6011fd39320ede34d493929f (diff) | |
| download | meshbay-2167b7cd7ec006ba3f736bc18970f86f15d88a2a.tar.gz | |
fix(spa): a reconnect no longer fails the download it reconnects for
While a reconnect ran, every request went out, not only its handshake: a
chunk retried between the new channel opening and the proof got
"Handshake required", which no retry treats as a dropped connection.
Only the opening messages pass now; a replaced channel's late close
event is ignored, and a closed channel is retried like a lost one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 57 |
2 files changed, 52 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index 585558f..ae6cd6f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -349,9 +349,13 @@ async function _writeOrStall(writable, bytes, at) { } } +// "Transport closed" is not here: that is close(), somebody ending the +// connection on purpose. "DataChannel closed" is the channel going away under +// it, which is what a reconnect is for. function _isRetryableTransportError(err) { return err.name === 'TransportLostError' || err.message === 'Response timeout' + || err.message === 'DataChannel closed' || (err.message || '').startsWith('DataChannel not open'); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index a07f431..15c7b09 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -447,6 +447,16 @@ const LEASE_WATCHDOG_MS = 60000; // one sender: 1 connection 5 to 6 MB/s, 4 about 19, 8 between 27 and 34, and // 16 no more than that (the node's sender is one Python process). const DOWNLOAD_LANES = 8; + +// What a connection sends while it is opening its session, before the node +// knows who is on it: the node answers anything else with "Handshake +// required" (webrtc/dispatch.py), and the same list is what it accepts there. +// While a reconnect runs these are the only messages that go out; everything +// else waits for the session (see `_sendAndWait`). +const SESSION_OPENING = new Set([ + 'handshake', 'handshake_response', 'keypair_bundle_fetch', 'gek_bundle_fetch', + 'join_request', 'device_add_request', 'device_hello', +]); // A lane left unused this long is closed: each one is a peer connection the // node holds open. const LANE_IDLE_MS = 30000; @@ -594,6 +604,10 @@ class MeshBayTransport { this._lanesTimer = 0; this._laneRetryAt = 0; this._lane = false; + // True from the end of connect() — the node has authenticated this side — + // until the connection is lost. Not `_connected`, which is true from the + // moment the channel opens, a whole handshake earlier. + this._sessionUp = false; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -810,6 +824,7 @@ class MeshBayTransport { // reconnect that never got as far as announcing a device still looked, to // the composer, exactly like one that had. this._setDevicePk('', 'new connection'); + this._sessionUp = false; this._pc = new RTCPeerConnection({ iceServers: await iceServers() }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); @@ -836,15 +851,23 @@ class MeshBayTransport { channelReady.catch(() => { /* the awaiter below reports it */ }); this._channel.onmessage = (event) => this._onMessage(event.data); + const channel = this._channel; this._channel.onclose = (ev) => { - console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); + console.warn('[MeshBay] DataChannel closed', channel.readyState, ev); trace('channel_close', { - readyState: this._channel?.readyState, + readyState: channel.readyState, pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, }); - this._connected = false; if (channelReject) channelReject(new Error('DataChannel closed')); + // A reconnect closes the old channel before it opens the next, and the + // event comes when it comes: from a channel that is no longer this + // connection's, it is news about nothing. Acted on, it marked the new + // session closed and failed whatever it had in flight, its own + // handshake included. + if (channel !== this._channel) return; + this._connected = false; + this._sessionUp = false; for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); this._pending.clear(); }; @@ -876,6 +899,7 @@ class MeshBayTransport { // sit out its own timeout instead of failing fast. if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) { this._connected = false; + this._sessionUp = false; this._reconnect(); const err = new Error('WebRTC connection lost'); err.name = 'TransportLostError'; @@ -1303,6 +1327,7 @@ class MeshBayTransport { console.warn('[MeshBay] device_hello failed — chat will not work:', e); }); + this._sessionUp = true; return ack; } @@ -1757,6 +1782,7 @@ class MeshBayTransport { if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; + this._sessionUp = false; for (const [, p] of this._pending) p.reject(new Error('Transport closed')); this._pending.clear(); } @@ -1823,12 +1849,25 @@ class MeshBayTransport { async _sendAndWait(obj, timeoutMs = 30000) { // A reconnect already in flight (see _reconnectLoop) means the channel - // this would send on is the one just declared dead. `_inReconnectAttempt` - // excludes the handshake connect() itself makes while reconnecting — that - // call runs *inside* this same _reconnectPromise, which cannot resolve - // until it returns, so waiting on it here would just be waiting on - // itself for the full 6s, on every step of the handshake, every time. - if (!this._inReconnectAttempt) await this.waitForReconnect(6000); + // this would send on is the one just declared dead. The handshake that + // connect() sends while reconnecting runs *inside* that same + // _reconnectPromise, which cannot resolve until it returns, so waiting on + // it here would be waiting on itself — those messages go straight out. + // + // Only those. This used to let *every* message through while the attempt + // ran, so a download's chunk asked for again in that window went out on a + // channel that had opened and not finished its handshake: the node + // answered "Handshake required", which no retry treats as a dropped + // connection, and a download was failed by the reconnect meant to save it. + const opening = SESSION_OPENING.has(obj.type); + if (!(opening && this._inReconnectAttempt)) await this.waitForReconnect(6000); + if (!opening && !this._sessionUp) { + // No session to send it on: what the caller sees on a dropped connection, + // and what the download path waits out (`_fetchChunkResilient`). + const err = new Error('WebRTC connection lost'); + err.name = 'TransportLostError'; + throw err; + } return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { |