aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-16 10:41:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-16 10:41:17 +0200
commit2167b7cd7ec006ba3f736bc18970f86f15d88a2a (patch)
tree5d2426b4fb4b32f6a00e8d3014d3675595e39df6 /packages/meshbay-hub/src/meshbay_hub
parent88f4ade0ee1e1a2b6011fd39320ede34d493929f (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js57
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(() => {