summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
blob: aac8225ba799eb6fc7e23dad1f8bf514ca2bce84 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// The connections to groups' nodes that search opens — and the music queue,
// which plays across groups — one per group, with one ceiling on how many are
// negotiated at once. A module of its own so the shell can hold a pool without
// loading the search page.

import { hubFetch, ensureFreshToken } from './hub-client.js';

// How many connections search negotiates at once — a ceiling on concurrency,
// never a batch (see `inFlight` in search-page.js), and one ceiling for everything: the
// sweep, the warm-up and a tile asking for its group all go through
// `ConnectionPool.connect`, which holds it. Three separate ceilings used to add
// up behind each other's backs, past what the hub admits per account.
//
// Six, sized for twenty groups on a phone: a connection there takes about two
// seconds, so three at a time is fourteen seconds to reach them all and six is
// seven, and a node that is down holds one place in six rather than one in
// three. The hub admits 32 pending offers per account (signaling.py), which
// leaves room for the search page on three devices at once plus their reconnections.
const MAX_IN_FLIGHT = 6;
// One WebRTC peer connection per group the search view touches. The cap bounds
// how many a busy account (many groups on the hub) keeps open at once; groups
// past it connect lazily when a tile of theirs scrolls into view (media-tiles.js's
// LazyTile → onNeedConn). Was 3, which meant any account with more than three
// groups thrashed the pool: an evicted transport left a stale `_tRef` on every
// tile of that group, and MediaThumb / useMediaMeta gave up on it with no
// retry — so posters rendered for a moment, then fell back to a spinner for
// good. grenet (one shared group) never hit it; cbesson (many) always did.
const MAX_POOL_SIZE = 12;

// How long a connection attempt may make **no progress**, not how long it may
// take. Search used a flat 10 s for the whole of `connect()`, which is the hub
// round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the
// DataChannel opening and the handshake's own round trips. Opening the same
// group from the sidebar has no such deadline and gets the transport's own 30 s,
// so a link slow enough to need twelve seconds — a phone on 4G — failed here and
// worked from there, for the same work against the same node.
//
// A node that is not answering produces no progress event and still fails in
// `SEARCH_STALL_MS`, which is what keeps the fan-out bounded: a dead group holds
// one of the `MAX_IN_FLIGHT` places for this long, so the number that matters
// for a page full of unreachable groups is this one. A hub that refuses an offer
// for load *is* answering, and transport.js reports each retry as progress.
// `SEARCH_MAX_MS` bounds the other case — a node that answers ICE and then stops
// — because a deadline that only ever resets has none.
const SEARCH_STALL_MS = 10000;
const SEARCH_MAX_MS = 30000;

// -- Connecting to a group ----------------------------------------------------

/**
 * A live connection to one of the nodes serving `groupId`, and its ack — or
 * null when the hub lists no node at all.
 *
 * Every node the hub lists, in turn, exactly as group-page.js does since
 * 2026-09-11: the list is in hub registration order, and its head is not
 * necessarily a node that answers. Search took `nodes[0]` and stopped, so a
 * group with a second, working node counted as unreachable here while it
 * opened fine from the sidebar. A refusal naming a state of this browser (a
 * code, a passphrase, a device) is the same from every node and stops the
 * walk; `not_hosted`, a timeout or a failed connection moves on.
 */
async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) {
  const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
  if (!nodesData.nodes || !nodesData.nodes.length) return null;

  const live = (await ensureFreshToken()) || token;
  let lastErr = null;
  for (const n of nodesData.nodes) {
    const transport = new window.MeshBayTransport(hubBase, live);
    transport.onNeedToken = async () => (await ensureFreshToken()) || token;
    let stallTimer, capTimer;
    const stopTimers = () => {
      clearTimeout(stallTimer);
      clearTimeout(capTimer);
      transport.onConnectProgress = null;
    };
    try {
      const ack = await Promise.race([
        transport.connect(
          n.node_id, live, groupId, null, null, bundleKey,
          username, userId, null),
        new Promise((_, reject) => {
          const giveUp = () => reject(new Error('Connection timeout'));
          stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
          capTimer = setTimeout(giveUp, SEARCH_MAX_MS);
          // Each step the transport reports — the peer answering ICE, the
          // channel opening — buys another window, never more than the cap.
          transport.onConnectProgress = () => {
            clearTimeout(stallTimer);
            stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
          };
        }),
      ]);
      stopTimers();
      return { transport, ack };
    } catch (e) {
      stopTimers();
      lastErr = e;
      try { transport.close(); } catch {}
      if (e.reason && e.reason !== 'not_hosted') throw e;
    }
  }
  throw (lastErr || new Error('no node served this group'));
}

// -- Connection pool ----------------------------------------------------------

/**
 * Every connection the search page makes, and the only way it makes one.
 *
 * The sweep, the warm-up and a tile asking for its group all come here, so the
 * page has one ceiling on how many it negotiates at once (`MAX_IN_FLIGHT`) and
 * one connection per group. The sweep used to open its own, fetch the index,
 * close it, and leave the warm-up to open the same group again straight after:
 * two offers per group per visit, and on 4G the two overlapped, which is how a
 * phone ran into the hub's per-account ceilings with five groups.
 */
class ConnectionPool {
  constructor(hubBase, onEvict) {
    this._hubBase = hubBase;
    this._connections = new Map();
    this._connecting = new Map();
    // Called with a groupId whenever this pool closes that group's connection
    // (eviction, close or closeAll). SearchPage uses it to drop its own record
    // so it never hands a tile a `_tRef` pointing at a transport closed here.
    this._onEvict = onEvict || (() => {});
    // Negotiations under way, and those waiting for one of the places.
    this._active = 0;
    this._waiting = [];
    this._closed = false;
  }

  get size() { return this._connections.size; }

  get closed() { return this._closed; }

  /** Whether `groupId` has a connection here that using costs no offer. */
  has(groupId) {
    const conn = this._connections.get(groupId);
    return !!(conn && conn.transport.connected);
  }

  /**
   * The group's connection, negotiating one if there is none.
   *
   * `hold` keeps it from being evicted until `release` — the sweep holds each
   * group while its index is on the way, because with more groups than the pool
   * keeps, the connection it is reading from would otherwise be the oldest one
   * and closed under it.
   */
  async connect(groupId, token, bundleKey, username, userId, { hold = false } = {}) {
    if (this._closed) throw new Error('Search was closed');
    let conn = this._connections.get(groupId);
    if (!(conn && conn.transport.connected)) {
      let p = this._connecting.get(groupId);
      if (!p) {
        p = this._negotiate(groupId, token, bundleKey, username, userId);
        this._connecting.set(groupId, p);
        p.finally(() => this._connecting.delete(groupId)).catch(() => {});
      }
      conn = await p;
    }
    conn.lastUsed = Date.now();
    if (hold) conn.holds += 1;
    this._evict();
    return conn;
  }

  release(conn) {
    conn.holds = Math.max(0, conn.holds - 1);
    this._evict();
  }

  /** Drop one group's connection, e.g. one that stopped answering. */
  close(groupId) {
    const conn = this._connections.get(groupId);
    if (!conn) return;
    try { conn.transport.close(); } catch {}
    this._connections.delete(groupId);
    this._onEvict(groupId);
  }

  async _negotiate(groupId, token, bundleKey, username, userId) {
    await this._takePlace();
    let conn;
    try {
      // Waiting for a place is not part of any deadline: `connectToGroup`'s
      // timers start inside `_doConnect`, once this negotiation is really on.
      if (this._closed) throw new Error('Search was closed');
      conn = await this._doConnect(groupId, token, bundleKey, username, userId);
    } finally {
      this._givePlace();
    }
    if (this._closed) {
      try { conn.transport.close(); } catch {}
      throw new Error('Search was closed');
    }
    // A connection that dropped is replaced, and closed so its own reconnect
    // loop does not keep negotiating a second one for the same group.
    const stale = this._connections.get(groupId);
    if (stale && stale.transport !== conn.transport) {
      try { stale.transport.close(); } catch {}
    }
    this._connections.set(groupId, conn);
    return conn;
  }

  _takePlace() {
    if (this._active < MAX_IN_FLIGHT) {
      this._active += 1;
      return Promise.resolve();
    }
    return new Promise((resolve) => this._waiting.push(resolve));
  }

  _givePlace() {
    const next = this._waiting.shift();
    if (next) next();          // handed straight over: `_active` is unchanged
    else this._active -= 1;
  }

  async _doConnect(groupId, token, bundleKey, username, userId) {
    const found = await connectToGroup(
      this._hubBase, groupId, token, bundleKey, username, userId);
    if (!found) throw new Error('offline');
    const { transport, ack } = found;

    let gek = null;
    if (transport.gekRaw && window.MeshBayCrypto) {
      gek = await window.MeshBayCrypto.importGEK(
        window.MeshBayCrypto.b64encode(transport.gekRaw));
    }
    return { transport, gek, ack, lastUsed: Date.now(), holds: 0 };
  }

  _evict() {
    while (this._connections.size > MAX_POOL_SIZE) {
      let oldestId = null, oldestTime = Infinity;
      for (const [id, conn] of this._connections) {
        if (conn.holds > 0) continue;
        if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; }
      }
      // Everything over the size is held: it goes when it is released.
      if (!oldestId) break;
      this.close(oldestId);
    }
  }

  closeAll() {
    // In-flight negotiations see this when they finish and close what they got.
    this._closed = true;
    for (const [id, conn] of this._connections) {
      try { conn.transport.close(); } catch {}
      this._onEvict(id);
    }
    this._connections.clear();
  }
}

export { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE };