@peerbit/pubsub 5.2.11 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +3 -0
  2. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +1 -0
  3. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +90 -0
  4. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +1 -0
  5. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +2 -0
  6. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +1 -0
  7. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +712 -0
  8. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +1 -0
  9. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +2 -0
  10. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +1 -0
  11. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +1591 -0
  12. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +1 -0
  13. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +2 -0
  14. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +1 -0
  15. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +297 -0
  16. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +1 -0
  17. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +73 -0
  18. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +1 -0
  19. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +183 -0
  20. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +1 -0
  21. package/dist/benchmark/fanout-tree-sim-lib.d.ts +101 -0
  22. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -1
  23. package/dist/benchmark/fanout-tree-sim-lib.js +540 -38
  24. package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -1
  25. package/dist/benchmark/fanout-tree-sim.d.ts +0 -9
  26. package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -1
  27. package/dist/benchmark/fanout-tree-sim.js +369 -28
  28. package/dist/benchmark/fanout-tree-sim.js.map +1 -1
  29. package/dist/benchmark/index.js +20 -0
  30. package/dist/benchmark/index.js.map +1 -1
  31. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +4 -0
  32. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -1
  33. package/dist/benchmark/pubsub-topic-sim-lib.js +58 -3
  34. package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -1
  35. package/dist/benchmark/pubsub-topic-sim.js +18 -0
  36. package/dist/benchmark/pubsub-topic-sim.js.map +1 -1
  37. package/dist/src/debounced-set.d.ts +1 -1
  38. package/dist/src/debounced-set.d.ts.map +1 -1
  39. package/dist/src/debounced-set.js +5 -1
  40. package/dist/src/debounced-set.js.map +1 -1
  41. package/dist/src/fanout-tree-codec.d.ts +330 -0
  42. package/dist/src/fanout-tree-codec.d.ts.map +1 -0
  43. package/dist/src/fanout-tree-codec.js +1209 -0
  44. package/dist/src/fanout-tree-codec.js.map +1 -0
  45. package/dist/src/fanout-tree-parent-upgrade.d.ts +117 -0
  46. package/dist/src/fanout-tree-parent-upgrade.d.ts.map +1 -0
  47. package/dist/src/fanout-tree-parent-upgrade.js +135 -0
  48. package/dist/src/fanout-tree-parent-upgrade.js.map +1 -0
  49. package/dist/src/fanout-tree.d.ts +245 -73
  50. package/dist/src/fanout-tree.d.ts.map +1 -1
  51. package/dist/src/fanout-tree.js +2290 -1057
  52. package/dist/src/fanout-tree.js.map +1 -1
  53. package/dist/src/index.d.ts +58 -0
  54. package/dist/src/index.d.ts.map +1 -1
  55. package/dist/src/index.js +316 -45
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/topic-root-control-plane.d.ts +13 -0
  58. package/dist/src/topic-root-control-plane.d.ts.map +1 -1
  59. package/dist/src/topic-root-control-plane.js +45 -0
  60. package/dist/src/topic-root-control-plane.js.map +1 -1
  61. package/package.json +9 -12
  62. package/src/debounced-set.ts +5 -0
  63. package/src/fanout-tree-codec.ts +1580 -0
  64. package/src/fanout-tree-parent-upgrade.ts +353 -0
  65. package/src/fanout-tree.ts +4558 -2230
  66. package/src/index.ts +425 -59
  67. package/src/topic-root-control-plane.ts +48 -0
@@ -1,15 +1,21 @@
1
- import { getPublicKeyFromPeerId, ready, sha256Sync, toBase64 } from "@peerbit/crypto";
2
1
  import { multiaddr } from "@multiformats/multiaddr";
2
+ import { getPublicKeyFromPeerId, ready, sha256Sync, toBase64, } from "@peerbit/crypto";
3
3
  import { DirectStream, dontThrowIfDeliveryError, } from "@peerbit/stream";
4
4
  import { AnyWhere, DataMessage, } from "@peerbit/stream-interface";
5
5
  import { AbortError, TimeoutError, delay } from "@peerbit/time";
6
6
  import { anySignal } from "any-signal";
7
7
  import { Uint8ArrayList } from "uint8arraylist";
8
8
  import { normalizeUint8Array, toUint8Array } from "./bytes.js";
9
+ import { JOIN_REJECT_LOW_BID, JOIN_REJECT_NO_CAPACITY, JOIN_REJECT_NOT_ATTACHED, JOIN_REJECT_REDIRECT_ADDR_MAX, JOIN_REJECT_REDIRECT_MAX, MSG_DATA, MSG_END, MSG_FETCH_REQ, MSG_IHAVE, MSG_JOIN_ACCEPT, MSG_JOIN_REJECT, MSG_JOIN_REQ, MSG_KICK, MSG_LEAVE, MSG_PARENT_PROBE_REPLY, MSG_PARENT_PROBE_REQ, MSG_PROVIDER_ANNOUNCE, MSG_PROVIDER_NOTIFY, MSG_PROVIDER_QUERY, MSG_PROVIDER_REPLY, MSG_PROVIDER_SUBSCRIBE, MSG_PROVIDER_UNSUBSCRIBE, MSG_PUBLISH_PROXY, MSG_REPAIR_REQ, MSG_ROUTE_QUERY, MSG_ROUTE_REPLY, MSG_TRACKER_ANNOUNCE, MSG_TRACKER_FEEDBACK, MSG_TRACKER_QUERY, MSG_TRACKER_REPLY, MSG_UNICAST, MSG_UNICAST_ACK, PARENT_PROBE_FLAG_ACCEPTING, PARENT_PROBE_FLAG_OVERLOADED, PARENT_PROBE_FLAG_REPAIRING, PARENT_PROBE_FLAG_ROOTED, TRACKER_FEEDBACK_DIAL_FAILED, TRACKER_FEEDBACK_JOINED, TRACKER_FEEDBACK_JOIN_REJECT, TRACKER_FEEDBACK_JOIN_TIMEOUT, UNICAST_ACK_DEFAULT_TIMEOUT_MS, clampU16, readU32BE, tsFanoutWireCodec, writeU32BE, } from "./fanout-tree-codec.js";
10
+ import { evaluateParentUpgradeGate, normalizeParentUpgradePolicy, recordParentUpgradeSkip, } from "./fanout-tree-parent-upgrade.js";
9
11
  import { TopicRootControlPlane } from "./topic-root-control-plane.js";
10
12
  const CONTROL_PRIORITY = 10;
11
13
  const DATA_PRIORITY = 1;
12
14
  const ROUTE_PROXY_TIMEOUT_MS = 10_000;
15
+ // Backstop bounds for the route-proxy machinery under query storms (#983):
16
+ // each pending entry retains a live timer closure and a Set of child hashes.
17
+ const ROUTE_PROXY_MAX_PENDING = 2_048;
18
+ const ROUTE_PROXY_MAX_WAITERS = 512;
13
19
  const ROUTE_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
14
20
  const PEER_HINT_MAX_ENTRIES_HARD_CAP = 100_000;
15
21
  // Best-effort address cache for join candidates learned via trackers/redirects.
@@ -35,8 +41,15 @@ const OVERLOAD_KICK_MAX_PER_EVENT = 4;
35
41
  const DATA_WRITE_FAIL_KICK_STREAK_THRESHOLD = 3;
36
42
  const DATA_WRITE_FAIL_KICK_COOLDOWN_MS = 2_000;
37
43
  const DATA_WRITE_FAIL_KICK_MAX_PER_EVENT = 4;
38
- const JOIN_REJECT_REDIRECT_MAX = 4;
39
- const JOIN_REJECT_REDIRECT_ADDR_MAX = 8;
44
+ const PARENT_REPAIR_DEAD_STREAK_THRESHOLD = 16;
45
+ const PARENT_REPAIR_DEAD_MIN_LIVENESS_MS = 15_000;
46
+ const REPAIR_RETRY_MIN_MS = 1_000;
47
+ const REPAIR_RETRY_INTERVAL_FACTOR = 5;
48
+ // A stream can remain locally readable/writable while silently dropping every
49
+ // control frame. Repeated unanswered JOIN requests are end-to-end evidence that
50
+ // the peer path needs to be rebuilt.
51
+ const JOIN_TIMEOUT_RESET_STREAK_THRESHOLD = 3;
52
+ const JOIN_TIMEOUT_RESET_COOLDOWN_MS = 10_000;
40
53
  const JOIN_REJECT_REDIRECT_QUEUE_MAX = 64;
41
54
  // When a relay loses its parent, pause before trying to rejoin so its children can
42
55
  // attach elsewhere (helps avoid disconnected components stabilizing).
@@ -45,622 +58,16 @@ const JOIN_REJECT_REDIRECT_QUEUE_MAX = 64;
45
58
  // This gives kicked children a window to reattach elsewhere (helps avoid stable
46
59
  // disconnected components). Leaf nodes (no children) rejoin immediately.
47
60
  const RELAY_REJOIN_COOLDOWN_MS = 3_000;
48
- const MSG_JOIN_REQ = 1;
49
- const MSG_JOIN_ACCEPT = 2;
50
- const MSG_JOIN_REJECT = 3;
51
- const MSG_KICK = 4;
52
- const MSG_DATA = 10;
53
- const MSG_END = 11;
54
- const MSG_UNICAST = 12;
55
- const MSG_ROUTE_QUERY = 13;
56
- const MSG_ROUTE_REPLY = 14;
57
- const MSG_PUBLISH_PROXY = 15;
58
- const MSG_LEAVE = 16;
59
- const MSG_UNICAST_ACK = 17;
60
- const MSG_REPAIR_REQ = 20;
61
- const MSG_FETCH_REQ = 21;
62
- const MSG_IHAVE = 22;
63
- const MSG_TRACKER_ANNOUNCE = 30;
64
- const MSG_TRACKER_QUERY = 31;
65
- const MSG_TRACKER_REPLY = 32;
66
- const MSG_TRACKER_FEEDBACK = 33;
67
- const MSG_PROVIDER_ANNOUNCE = 34;
68
- const MSG_PROVIDER_QUERY = 35;
69
- const MSG_PROVIDER_REPLY = 36;
70
- const MSG_PROVIDER_SUBSCRIBE = 37;
71
- const MSG_PROVIDER_UNSUBSCRIBE = 38;
72
- const MSG_PROVIDER_NOTIFY = 39;
73
- const JOIN_REJECT_NOT_ATTACHED = 1;
74
- const JOIN_REJECT_NO_CAPACITY = 2;
75
- const JOIN_REJECT_LOW_BID = 3;
76
- const TRACKER_FEEDBACK_JOINED = 1;
77
- const TRACKER_FEEDBACK_DIAL_FAILED = 2;
78
- const TRACKER_FEEDBACK_JOIN_TIMEOUT = 3;
79
- const TRACKER_FEEDBACK_JOIN_REJECT = 4;
80
- const textEncoder = new TextEncoder();
81
- const textDecoder = new TextDecoder();
82
- const writeU32BE = (buf, offset, value) => {
83
- buf[offset + 0] = (value >>> 24) & 0xff;
84
- buf[offset + 1] = (value >>> 16) & 0xff;
85
- buf[offset + 2] = (value >>> 8) & 0xff;
86
- buf[offset + 3] = value & 0xff;
87
- };
88
- const readU32BE = (buf, offset) => ((buf[offset + 0] << 24) |
89
- (buf[offset + 1] << 16) |
90
- (buf[offset + 2] << 8) |
91
- buf[offset + 3]) >>> 0;
92
- const writeU64BE = (buf, offset, value) => {
93
- let v = value & 0xffffffffffffffffn;
94
- for (let i = 7; i >= 0; i--) {
95
- buf[offset + i] = Number(v & 0xffn);
96
- v >>= 8n;
97
- }
98
- };
99
- const readU64BE = (buf, offset) => {
100
- let v = 0n;
101
- for (let i = 0; i < 8; i++) {
102
- v = (v << 8n) | BigInt(buf[offset + i]);
103
- }
104
- return v;
105
- };
106
- const writeU16BE = (buf, offset, value) => {
107
- buf[offset + 0] = (value >>> 8) & 0xff;
108
- buf[offset + 1] = value & 0xff;
109
- };
110
- const readU16BE = (buf, offset) => ((buf[offset + 0] << 8) | buf[offset + 1]) >>> 0;
111
- const encodeJoinReq = (channelKey, reqId, bidPerByte) => {
112
- const buf = new Uint8Array(1 + 32 + 4 + 4);
113
- buf[0] = MSG_JOIN_REQ;
114
- buf.set(channelKey, 1);
115
- writeU32BE(buf, 33, reqId >>> 0);
116
- writeU32BE(buf, 37, bidPerByte >>> 0);
117
- return buf;
118
- };
119
- const MAX_ROUTE_HOPS = 32;
120
- const UNICAST_FLAG_ACK = 1;
121
- const UNICAST_ACK_DEFAULT_TIMEOUT_MS = 30_000;
122
- const encodeJoinAccept = (channelKey, reqId, level, parentRouteFromRoot) => {
123
- const routeBytes = [];
124
- let bytes = 1 + 32 + 4 + 2 + 1;
125
- let count = 0;
126
- for (const hop of parentRouteFromRoot ?? []) {
127
- if (count >= MAX_ROUTE_HOPS)
128
- break;
129
- if (typeof hop !== "string")
130
- continue;
131
- const hb = textEncoder.encode(hop);
132
- if (hb.length === 0 || hb.length > 255)
133
- continue;
134
- routeBytes.push(hb);
135
- bytes += 1 + hb.length;
136
- count += 1;
137
- }
138
- const buf = new Uint8Array(bytes);
139
- buf[0] = MSG_JOIN_ACCEPT;
140
- buf.set(channelKey, 1);
141
- writeU32BE(buf, 33, reqId >>> 0);
142
- writeU16BE(buf, 37, level & 0xffff);
143
- buf[39] = count & 0xff;
144
- let offset = 40;
145
- for (const hb of routeBytes) {
146
- buf[offset++] = hb.length & 0xff;
147
- buf.set(hb, offset);
148
- offset += hb.length;
149
- }
150
- return buf;
151
- };
152
- const encodeJoinReject = (channelKey, reqId, reason, redirects) => {
153
- const encoded = [];
154
- for (const r of redirects ?? []) {
155
- if (!r?.hash)
156
- continue;
157
- const hashBytes = textEncoder.encode(r.hash);
158
- if (hashBytes.length === 0 || hashBytes.length > 255)
159
- continue;
160
- const addrs = (r.addrs ?? [])
161
- .filter((a) => a instanceof Uint8Array)
162
- .filter((a) => a.length > 0 && a.length <= 0xffff)
163
- .slice(0, JOIN_REJECT_REDIRECT_ADDR_MAX);
164
- if (addrs.length === 0)
165
- continue;
166
- encoded.push({ hashBytes, addrs });
167
- if (encoded.length >= JOIN_REJECT_REDIRECT_MAX)
168
- break;
169
- }
170
- if (encoded.length === 0) {
171
- const buf = new Uint8Array(1 + 32 + 4 + 1);
172
- buf[0] = MSG_JOIN_REJECT;
173
- buf.set(channelKey, 1);
174
- writeU32BE(buf, 33, reqId >>> 0);
175
- buf[37] = reason & 0xff;
176
- return buf;
177
- }
178
- let bytes = 1 + 32 + 4 + 1 + 1;
179
- for (const e of encoded) {
180
- bytes += 1 + e.hashBytes.length + 1;
181
- for (const a of e.addrs)
182
- bytes += 2 + a.length;
183
- }
184
- const buf = new Uint8Array(bytes);
185
- buf[0] = MSG_JOIN_REJECT;
186
- buf.set(channelKey, 1);
187
- writeU32BE(buf, 33, reqId >>> 0);
188
- buf[37] = reason & 0xff;
189
- buf[38] = Math.max(0, Math.min(255, encoded.length)) & 0xff;
190
- let offset = 39;
191
- for (const e of encoded) {
192
- buf[offset++] = e.hashBytes.length & 0xff;
193
- buf.set(e.hashBytes, offset);
194
- offset += e.hashBytes.length;
195
- buf[offset++] = Math.max(0, Math.min(255, e.addrs.length)) & 0xff;
196
- for (const a of e.addrs) {
197
- writeU16BE(buf, offset, a.length);
198
- offset += 2;
199
- buf.set(a, offset);
200
- offset += a.length;
201
- }
202
- }
203
- return buf;
204
- };
205
- const encodeKick = (channelKey) => {
206
- const buf = new Uint8Array(1 + 32);
207
- buf[0] = MSG_KICK;
208
- buf.set(channelKey, 1);
209
- return buf;
210
- };
211
- const encodeEnd = (channelKey, lastSeqExclusive) => {
212
- const buf = new Uint8Array(1 + 32 + 4);
213
- buf[0] = MSG_END;
214
- buf.set(channelKey, 1);
215
- writeU32BE(buf, 33, lastSeqExclusive >>> 0);
216
- return buf;
217
- };
218
- const encodeRepairReq = (channelKey, reqId, missingSeqs) => {
219
- const count = Math.max(0, Math.min(255, missingSeqs.length));
220
- const buf = new Uint8Array(1 + 32 + 4 + 1 + count * 4);
221
- buf[0] = MSG_REPAIR_REQ;
222
- buf.set(channelKey, 1);
223
- writeU32BE(buf, 33, reqId >>> 0);
224
- buf[37] = count & 0xff;
225
- for (let i = 0; i < count; i++) {
226
- writeU32BE(buf, 38 + i * 4, missingSeqs[i] >>> 0);
227
- }
228
- return buf;
229
- };
230
- const encodeFetchReq = (channelKey, reqId, missingSeqs) => {
231
- const count = Math.max(0, Math.min(255, missingSeqs.length));
232
- const buf = new Uint8Array(1 + 32 + 4 + 1 + count * 4);
233
- buf[0] = MSG_FETCH_REQ;
234
- buf.set(channelKey, 1);
235
- writeU32BE(buf, 33, reqId >>> 0);
236
- buf[37] = count & 0xff;
237
- for (let i = 0; i < count; i++) {
238
- writeU32BE(buf, 38 + i * 4, missingSeqs[i] >>> 0);
239
- }
240
- return buf;
241
- };
242
- const encodeIHave = (channelKey, haveFrom, haveToExclusive) => {
243
- const buf = new Uint8Array(1 + 32 + 4 + 4);
244
- buf[0] = MSG_IHAVE;
245
- buf.set(channelKey, 1);
246
- writeU32BE(buf, 33, haveFrom >>> 0);
247
- writeU32BE(buf, 37, haveToExclusive >>> 0);
248
- return buf;
249
- };
250
- const encodeData = (payload) => {
251
- const buf = new Uint8Array(1 + payload.length);
252
- buf[0] = MSG_DATA;
253
- buf.set(payload, 1);
254
- return buf;
255
- };
256
- const encodePublishProxy = (channelKey, payload) => {
257
- const buf = new Uint8Array(1 + 32 + payload.length);
258
- buf[0] = MSG_PUBLISH_PROXY;
259
- buf.set(channelKey, 1);
260
- buf.set(payload, 33);
261
- return buf;
262
- };
263
- const encodeLeave = (channelKey) => {
264
- const buf = new Uint8Array(1 + 32);
265
- buf[0] = MSG_LEAVE;
266
- buf.set(channelKey, 1);
267
- return buf;
268
- };
269
- const encodeUnicast = (channelKey, route, payload, options) => {
270
- const wantsAck = options?.ackToken != null;
271
- const flags = wantsAck ? UNICAST_FLAG_ACK : 0;
272
- const toRouteBytes = [];
273
- let bytes = 1 + 32 + 1 + (wantsAck ? 8 : 0) + 1;
274
- let toCount = 0;
275
- for (const hop of route ?? []) {
276
- if (toCount >= MAX_ROUTE_HOPS)
277
- break;
278
- if (typeof hop !== "string")
279
- continue;
280
- const hb = textEncoder.encode(hop);
281
- if (hb.length === 0 || hb.length > 255)
282
- continue;
283
- toRouteBytes.push(hb);
284
- bytes += 1 + hb.length;
285
- toCount += 1;
286
- }
287
- const replyRouteBytes = [];
288
- let replyCount = 0;
289
- if (wantsAck) {
290
- bytes += 1; // replyRouteCount
291
- for (const hop of options?.replyRoute ?? []) {
292
- if (replyCount >= MAX_ROUTE_HOPS)
293
- break;
294
- if (typeof hop !== "string")
295
- continue;
296
- const hb = textEncoder.encode(hop);
297
- if (hb.length === 0 || hb.length > 255)
298
- continue;
299
- replyRouteBytes.push(hb);
300
- bytes += 1 + hb.length;
301
- replyCount += 1;
302
- }
303
- }
304
- bytes += payload.byteLength;
305
- const buf = new Uint8Array(bytes);
306
- buf[0] = MSG_UNICAST;
307
- buf.set(channelKey, 1);
308
- buf[33] = flags & 0xff;
309
- let offset = 34;
310
- if (wantsAck) {
311
- writeU64BE(buf, offset, options.ackToken);
312
- offset += 8;
313
- }
314
- buf[offset++] = toCount & 0xff;
315
- for (const hb of toRouteBytes) {
316
- buf[offset++] = hb.length & 0xff;
317
- buf.set(hb, offset);
318
- offset += hb.length;
319
- }
320
- if (wantsAck) {
321
- buf[offset++] = replyCount & 0xff;
322
- for (const hb of replyRouteBytes) {
323
- buf[offset++] = hb.length & 0xff;
324
- buf.set(hb, offset);
325
- offset += hb.length;
326
- }
327
- }
328
- buf.set(payload, offset);
329
- return buf;
330
- };
331
- const encodeUnicastAck = (channelKey, ackToken, route) => {
332
- const routeBytes = [];
333
- let bytes = 1 + 32 + 8 + 1;
334
- let count = 0;
335
- for (const hop of route ?? []) {
336
- if (count >= MAX_ROUTE_HOPS)
337
- break;
338
- if (typeof hop !== "string")
339
- continue;
340
- const hb = textEncoder.encode(hop);
341
- if (hb.length === 0 || hb.length > 255)
342
- continue;
343
- routeBytes.push(hb);
344
- bytes += 1 + hb.length;
345
- count += 1;
346
- }
347
- const buf = new Uint8Array(bytes);
348
- buf[0] = MSG_UNICAST_ACK;
349
- buf.set(channelKey, 1);
350
- writeU64BE(buf, 33, ackToken);
351
- buf[41] = count & 0xff;
352
- let offset = 42;
353
- for (const hb of routeBytes) {
354
- buf[offset++] = hb.length & 0xff;
355
- buf.set(hb, offset);
356
- offset += hb.length;
357
- }
358
- return buf;
359
- };
360
- const encodeRouteQuery = (channelKey, reqId, targetHash) => {
361
- const targetBytes = textEncoder.encode(targetHash);
362
- const targetLen = Math.max(0, Math.min(255, targetBytes.length));
363
- const buf = new Uint8Array(1 + 32 + 4 + 1 + targetLen);
364
- buf[0] = MSG_ROUTE_QUERY;
365
- buf.set(channelKey, 1);
366
- writeU32BE(buf, 33, reqId >>> 0);
367
- buf[37] = targetLen & 0xff;
368
- buf.set(targetBytes.subarray(0, targetLen), 38);
369
- return buf;
370
- };
371
- const encodeRouteReply = (channelKey, reqId, route) => {
372
- const routeBytes = [];
373
- let bytes = 1 + 32 + 4 + 1;
374
- let count = 0;
375
- for (const hop of route ?? []) {
376
- if (count >= MAX_ROUTE_HOPS)
377
- break;
378
- if (typeof hop !== "string")
379
- continue;
380
- const hb = textEncoder.encode(hop);
381
- if (hb.length === 0 || hb.length > 255)
382
- continue;
383
- routeBytes.push(hb);
384
- bytes += 1 + hb.length;
385
- count += 1;
386
- }
387
- const buf = new Uint8Array(bytes);
388
- buf[0] = MSG_ROUTE_REPLY;
389
- buf.set(channelKey, 1);
390
- writeU32BE(buf, 33, reqId >>> 0);
391
- buf[37] = count & 0xff;
392
- let offset = 38;
393
- for (const hb of routeBytes) {
394
- buf[offset++] = hb.length & 0xff;
395
- buf.set(hb, offset);
396
- offset += hb.length;
397
- }
398
- return buf;
399
- };
400
- const decodeRoute = (data, offsetStart, routeCount) => {
401
- let offset = offsetStart;
402
- const route = [];
403
- for (let i = 0; i < routeCount; i++) {
404
- if (offset + 1 > data.length)
405
- break;
406
- const len = data[offset++];
407
- if (len === 0)
408
- break;
409
- if (offset + len > data.length)
410
- break;
411
- if (route.length < MAX_ROUTE_HOPS) {
412
- route.push(textDecoder.decode(data.subarray(offset, offset + len)));
413
- }
414
- offset += len;
415
- }
416
- return { route, offset };
417
- };
418
- const clampU16 = (v) => Math.max(0, Math.min(0xffff, v | 0));
419
- const encodeTrackerAnnounce = (channelKey, ttlMs, level, maxChildren, freeSlots, bidPerByte, addrs) => {
420
- const addrCount = Math.max(0, Math.min(255, addrs.length));
421
- let bytes = 1 + 32 + 4 + 2 + 2 + 2 + 4 + 1;
422
- const addrBytes = new Array(addrCount);
423
- for (let i = 0; i < addrCount; i++) {
424
- const b = addrs[i].bytes;
425
- addrBytes[i] = b;
426
- bytes += 2 + b.length;
427
- }
428
- const buf = new Uint8Array(bytes);
429
- buf[0] = MSG_TRACKER_ANNOUNCE;
430
- buf.set(channelKey, 1);
431
- writeU32BE(buf, 33, Math.max(0, Math.floor(ttlMs)) >>> 0);
432
- writeU16BE(buf, 37, clampU16(level));
433
- writeU16BE(buf, 39, clampU16(maxChildren));
434
- writeU16BE(buf, 41, clampU16(freeSlots));
435
- writeU32BE(buf, 43, Math.max(0, Math.floor(bidPerByte)) >>> 0);
436
- buf[47] = addrCount & 0xff;
437
- let offset = 48;
438
- for (let i = 0; i < addrCount; i++) {
439
- const b = addrBytes[i];
440
- writeU16BE(buf, offset, b.length);
441
- offset += 2;
442
- buf.set(b, offset);
443
- offset += b.length;
444
- }
445
- return buf;
446
- };
447
- const encodeTrackerQuery = (channelKey, reqId, want) => {
448
- const buf = new Uint8Array(1 + 32 + 4 + 2);
449
- buf[0] = MSG_TRACKER_QUERY;
450
- buf.set(channelKey, 1);
451
- writeU32BE(buf, 33, reqId >>> 0);
452
- writeU16BE(buf, 37, clampU16(want));
453
- return buf;
454
- };
455
- const encodeTrackerReply = (channelKey, reqId, entries) => {
456
- const count = Math.max(0, Math.min(255, entries.length));
457
- let bytes = 1 + 32 + 4 + 1;
458
- const encoded = [];
459
- for (let i = 0; i < count; i++) {
460
- const e = entries[i];
461
- const hashBytes = textEncoder.encode(e.hash);
462
- if (hashBytes.length > 255)
463
- continue;
464
- const addrCount = Math.max(0, Math.min(255, e.addrs.length));
465
- const addrs = e.addrs.slice(0, addrCount);
466
- bytes += 1 + hashBytes.length + 2 + 2 + 4 + 1;
467
- for (const a of addrs)
468
- bytes += 2 + a.length;
469
- encoded.push({
470
- hashBytes,
471
- level: e.level,
472
- freeSlots: e.freeSlots,
473
- bidPerByte: e.bidPerByte,
474
- addrs,
475
- });
476
- }
477
- const buf = new Uint8Array(bytes);
478
- buf[0] = MSG_TRACKER_REPLY;
479
- buf.set(channelKey, 1);
480
- writeU32BE(buf, 33, reqId >>> 0);
481
- buf[37] = Math.max(0, Math.min(255, encoded.length)) & 0xff;
482
- let offset = 38;
483
- for (const e of encoded) {
484
- buf[offset++] = e.hashBytes.length & 0xff;
485
- buf.set(e.hashBytes, offset);
486
- offset += e.hashBytes.length;
487
- writeU16BE(buf, offset, clampU16(e.level));
488
- offset += 2;
489
- writeU16BE(buf, offset, clampU16(e.freeSlots));
490
- offset += 2;
491
- writeU32BE(buf, offset, Math.max(0, Math.floor(e.bidPerByte)) >>> 0);
492
- offset += 4;
493
- buf[offset++] = Math.max(0, Math.min(255, e.addrs.length)) & 0xff;
494
- for (const a of e.addrs) {
495
- writeU16BE(buf, offset, a.length);
496
- offset += 2;
497
- buf.set(a, offset);
498
- offset += a.length;
499
- }
500
- }
501
- return buf;
502
- };
503
- const encodeTrackerFeedback = (channelKey, candidateHash, event, reason) => {
504
- const hashBytes = textEncoder.encode(candidateHash);
505
- const hashLen = Math.max(0, Math.min(255, hashBytes.length));
506
- const buf = new Uint8Array(1 + 32 + 1 + hashLen + 1 + 1);
507
- buf[0] = MSG_TRACKER_FEEDBACK;
508
- buf.set(channelKey, 1);
509
- buf[33] = hashLen & 0xff;
510
- buf.set(hashBytes.subarray(0, hashLen), 34);
511
- buf[34 + hashLen] = event & 0xff;
512
- buf[34 + hashLen + 1] = reason & 0xff;
513
- return buf;
514
- };
515
- const encodeProviderAnnounce = (namespaceKey, ttlMs, addrs) => {
516
- const addrCount = Math.max(0, Math.min(255, addrs.length));
517
- let bytes = 1 + 32 + 4 + 1;
518
- const addrBytes = new Array(addrCount);
519
- for (let i = 0; i < addrCount; i++) {
520
- const b = addrs[i].bytes;
521
- addrBytes[i] = b;
522
- bytes += 2 + b.length;
523
- }
524
- const buf = new Uint8Array(bytes);
525
- buf[0] = MSG_PROVIDER_ANNOUNCE;
526
- buf.set(namespaceKey, 1);
527
- writeU32BE(buf, 33, Math.max(0, Math.floor(ttlMs)) >>> 0);
528
- buf[37] = addrCount & 0xff;
529
- let offset = 38;
530
- for (let i = 0; i < addrCount; i++) {
531
- const b = addrBytes[i];
532
- writeU16BE(buf, offset, b.length);
533
- offset += 2;
534
- buf.set(b, offset);
535
- offset += b.length;
536
- }
537
- return buf;
538
- };
539
- const encodeProviderQuery = (namespaceKey, reqId, want, seed) => {
540
- const buf = new Uint8Array(1 + 32 + 4 + 2 + 4);
541
- buf[0] = MSG_PROVIDER_QUERY;
542
- buf.set(namespaceKey, 1);
543
- writeU32BE(buf, 33, reqId >>> 0);
544
- writeU16BE(buf, 37, clampU16(want));
545
- writeU32BE(buf, 39, seed >>> 0);
546
- return buf;
547
- };
548
- const encodeProviderEntries = (entries) => {
549
- const count = Math.max(0, Math.min(255, entries.length));
550
- let bytes = 1;
551
- const encoded = [];
552
- for (let i = 0; i < count; i++) {
553
- const e = entries[i];
554
- const hashBytes = textEncoder.encode(e.hash);
555
- if (hashBytes.length > 255)
556
- continue;
557
- const addrCount = Math.max(0, Math.min(255, e.addrs.length));
558
- const addrs = e.addrs.slice(0, addrCount);
559
- bytes += 1 + hashBytes.length + 1;
560
- for (const a of addrs)
561
- bytes += 2 + a.length;
562
- encoded.push({ hashBytes, addrs });
563
- }
564
- return { bytes, encoded };
565
- };
566
- const encodeProviderReply = (namespaceKey, reqId, entries) => {
567
- const { bytes: entryBytes, encoded } = encodeProviderEntries(entries);
568
- let bytes = 1 + 32 + 4 + entryBytes;
569
- const buf = new Uint8Array(bytes);
570
- buf[0] = MSG_PROVIDER_REPLY;
571
- buf.set(namespaceKey, 1);
572
- writeU32BE(buf, 33, reqId >>> 0);
573
- buf[37] = Math.max(0, Math.min(255, encoded.length)) & 0xff;
574
- let offset = 38;
575
- for (const e of encoded) {
576
- buf[offset++] = e.hashBytes.length & 0xff;
577
- buf.set(e.hashBytes, offset);
578
- offset += e.hashBytes.length;
579
- buf[offset++] = Math.max(0, Math.min(255, e.addrs.length)) & 0xff;
580
- for (const a of e.addrs) {
581
- writeU16BE(buf, offset, a.length);
582
- offset += 2;
583
- buf.set(a, offset);
584
- offset += a.length;
585
- }
586
- }
587
- return buf;
588
- };
589
- const encodeProviderSubscribe = (namespaceKey, want, ttlMs) => {
590
- const buf = new Uint8Array(1 + 32 + 2 + 4);
591
- buf[0] = MSG_PROVIDER_SUBSCRIBE;
592
- buf.set(namespaceKey, 1);
593
- writeU16BE(buf, 33, clampU16(want));
594
- writeU32BE(buf, 35, Math.max(0, Math.floor(ttlMs)) >>> 0);
595
- return buf;
596
- };
597
- const encodeProviderUnsubscribe = (namespaceKey) => {
598
- const buf = new Uint8Array(1 + 32);
599
- buf[0] = MSG_PROVIDER_UNSUBSCRIBE;
600
- buf.set(namespaceKey, 1);
601
- return buf;
602
- };
603
- const encodeProviderNotify = (namespaceKey, entries) => {
604
- const { bytes: entryBytes, encoded } = encodeProviderEntries(entries);
605
- let bytes = 1 + 32 + entryBytes;
606
- const buf = new Uint8Array(bytes);
607
- buf[0] = MSG_PROVIDER_NOTIFY;
608
- buf.set(namespaceKey, 1);
609
- buf[33] = Math.max(0, Math.min(255, encoded.length)) & 0xff;
610
- let offset = 34;
611
- for (const e of encoded) {
612
- buf[offset++] = e.hashBytes.length & 0xff;
613
- buf.set(e.hashBytes, offset);
614
- offset += e.hashBytes.length;
615
- buf[offset++] = Math.max(0, Math.min(255, e.addrs.length)) & 0xff;
616
- for (const a of e.addrs) {
617
- writeU16BE(buf, offset, a.length);
618
- offset += 2;
619
- buf.set(a, offset);
620
- offset += a.length;
621
- }
622
- }
623
- return buf;
624
- };
625
- const decodeProviderEntries = (data, offsetStart, maxCount) => {
626
- let offset = offsetStart;
627
- const providers = [];
628
- const limit = Math.min(maxCount, 255);
629
- for (let i = 0; i < limit; i++) {
630
- if (offset + 1 > data.length)
631
- break;
632
- const hashLen = data[offset++];
633
- if (offset + hashLen > data.length)
634
- break;
635
- const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
636
- offset += hashLen;
637
- if (offset + 1 > data.length)
638
- break;
639
- const addrCount = data[offset++];
640
- const addrs = [];
641
- const addrMax = Math.min(addrCount, 16);
642
- for (let j = 0; j < addrMax; j++) {
643
- if (offset + 2 > data.length)
644
- break;
645
- const len = readU16BE(data, offset);
646
- offset += 2;
647
- if (offset + len > data.length)
648
- break;
649
- const bytes = data.subarray(offset, offset + len);
650
- offset += len;
651
- try {
652
- addrs.push(multiaddr(bytes));
653
- }
654
- catch {
655
- // ignore invalid
656
- }
657
- }
658
- if (!hash)
659
- continue;
660
- providers.push({ hash, addrs });
61
+ const PARENT_UPGRADE_ROOT_RESERVATION_TTL_MS = 5_000;
62
+ const stableUnitInterval = (input) => {
63
+ let hash = 0x811c9dc5;
64
+ for (let i = 0; i < input.length; i++) {
65
+ hash ^= input.charCodeAt(i);
66
+ hash = Math.imul(hash, 0x01000193);
661
67
  }
662
- return { providers, offset };
68
+ return (hash >>> 0) / 0x100000000;
663
69
  };
70
+ const textEncoder = new TextEncoder();
664
71
  const createDeferred = () => {
665
72
  let resolve;
666
73
  let reject;
@@ -695,11 +102,50 @@ const createEmptyMetrics = () => ({
695
102
  joinAcceptReceived: 0,
696
103
  joinRejectSent: 0,
697
104
  joinRejectReceived: 0,
105
+ joinPeerResets: 0,
698
106
  kickSent: 0,
699
107
  kickReceived: 0,
700
108
  reparentDisconnect: 0,
701
109
  reparentStale: 0,
702
110
  reparentKicked: 0,
111
+ reparentUpgrade: 0,
112
+ reparentUpgradeSkipLeaf: 0,
113
+ reparentUpgradeSkipRepair: 0,
114
+ reparentUpgradeSkipData: 0,
115
+ reparentUpgradeSkipCooldown: 0,
116
+ reparentUpgradeSkipQuiet: 0,
117
+ reparentUpgradeSkipBudget: 0,
118
+ reparentUpgradeSkipCandidateLevel: 0,
119
+ reparentUpgradeSkipCandidateSlots: 0,
120
+ reparentUpgradeSkipCandidatePressure: 0,
121
+ reparentUpgradeSkipRootPressure: 0,
122
+ reparentUpgradeSkipProbeNoReply: 0,
123
+ reparentUpgradeSkipProbeNotRooted: 0,
124
+ reparentUpgradeSkipProbeRepair: 0,
125
+ reparentUpgradeSkipProbeLag: 0,
126
+ reparentUpgradeSkipProbeOverloaded: 0,
127
+ reparentUpgradeSkipProbeCooldown: 0,
128
+ parentProbeReqSent: 0,
129
+ parentProbeReqReceived: 0,
130
+ parentProbeReplySent: 0,
131
+ parentProbeReplyReceived: 0,
132
+ parentUpgradeRootReservationCreated: 0,
133
+ parentUpgradeRootReservationConsumed: 0,
134
+ parentUpgradeRootReservationRejected: 0,
135
+ parentUpgradeRootReservationMarginRejected: 0,
136
+ parentUpgradeRootReservationBlocked: 0,
137
+ parentUpgradeRootReservationExpired: 0,
138
+ parentShadowStart: 0,
139
+ parentShadowObserve: 0,
140
+ parentShadowPromote: 0,
141
+ parentShadowReset: 0,
142
+ parentShadowRejectNoReply: 0,
143
+ parentShadowRejectNotRooted: 0,
144
+ parentShadowRejectCapacity: 0,
145
+ parentShadowRejectRepair: 0,
146
+ parentShadowRejectLag: 0,
147
+ parentShadowRejectOverloaded: 0,
148
+ parentShadowRejectLevel: 0,
703
149
  endSent: 0,
704
150
  endReceived: 0,
705
151
  repairReqSent: 0,
@@ -727,6 +173,8 @@ const createEmptyMetrics = () => ({
727
173
  routeProxyQueries: 0,
728
174
  routeProxyTimeouts: 0,
729
175
  routeProxyFanout: 0,
176
+ routeProxyCoalesced: 0,
177
+ routeProxyRejected: 0,
730
178
  });
731
179
  const isDataId = (id) => id.length === 32 &&
732
180
  id[0] === ID_PREFIX[0] &&
@@ -737,6 +185,8 @@ export class FanoutTree extends DirectStream {
737
185
  channelsBySuffixKey = new Map();
738
186
  cachedSuffixKey = new WeakMap();
739
187
  metricsBySuffixKey = new Map();
188
+ joinTimeoutStreakByPeer = new Map();
189
+ joinResetCooldownUntilByPeer = new Map();
740
190
  bootstraps = [];
741
191
  trackerBySuffixKey = new Map();
742
192
  trackerNamespaceLru = new Map();
@@ -748,20 +198,33 @@ export class FanoutTree extends DirectStream {
748
198
  underlayPeerDisconnectHandler;
749
199
  pendingProviderQueryBySuffixKey = new Map();
750
200
  providerAnnounceBySuffixKey = new Map();
201
+ parentUpgradeShadowInFlightSuffixKey;
751
202
  defaultUploadOverheadBytes = 128;
752
203
  random;
753
204
  unicastAckNodeTag32;
754
205
  unicastAckSeq = 0;
755
206
  topicRootControlPlane;
207
+ // Native fanout components of the rust-core DirectStream engine
208
+ // (`@peerbit/network-rust`). When set, every frame encode/decode of the
209
+ // `/peerbit/fanout-tree/0.5.0` codec plus the parent-upgrade policy/gate
210
+ // decisions run natively (byte- and decision-identical, covered by the
211
+ // golden parity suite); the channel state machine, timers and events
212
+ // stay host-side. Unset (the default) leaves every code path as-is.
213
+ nativeFanout;
214
+ codec;
756
215
  constructor(components, opts) {
757
- const { random, topicRootControlPlane, ...rest } = (opts ?? {});
216
+ const { random, topicRootControlPlane, ...rest } = (opts ??
217
+ {});
758
218
  super(components, FANOUT_PROTOCOLS, {
759
219
  canRelayMessage: false,
760
220
  ...rest,
761
221
  });
762
222
  this.random = typeof random === "function" ? random : Math.random;
223
+ this.nativeFanout = this.rustCore?.fanout;
224
+ this.codec = this.nativeFanout ?? tsFanoutWireCodec;
763
225
  this.unicastAckNodeTag32 = readU32BE(sha256Sync(textEncoder.encode(this.publicKeyHash)), 0);
764
- this.topicRootControlPlane = topicRootControlPlane ?? new TopicRootControlPlane();
226
+ this.topicRootControlPlane =
227
+ topicRootControlPlane ?? new TopicRootControlPlane();
765
228
  const onPeerDisconnect = (ev) => {
766
229
  const peerId = ev?.detail;
767
230
  if (!peerId)
@@ -783,6 +246,8 @@ export class FanoutTree extends DirectStream {
783
246
  this.components.events.removeEventListener("peer:disconnect", this.underlayPeerDisconnectHandler);
784
247
  this.underlayPeerDisconnectHandler = undefined;
785
248
  }
249
+ this.joinTimeoutStreakByPeer.clear();
250
+ this.joinResetCooldownUntilByPeer.clear();
786
251
  return super.stop();
787
252
  }
788
253
  setBootstraps(addrs) {
@@ -878,6 +343,24 @@ export class FanoutTree extends DirectStream {
878
343
  }
879
344
  return cached;
880
345
  }
346
+ /** Decoded provider entries (raw multiaddr bytes) -> candidates. Invalid
347
+ * multiaddrs are dropped exactly like the historical inline parsing. */
348
+ toProviderCandidates(entries) {
349
+ const candidates = [];
350
+ for (const entry of entries) {
351
+ const addrs = [];
352
+ for (const bytes of entry.addrs) {
353
+ try {
354
+ addrs.push(multiaddr(bytes));
355
+ }
356
+ catch {
357
+ // ignore invalid multiaddrs
358
+ }
359
+ }
360
+ candidates.push({ hash: entry.hash, addrs });
361
+ }
362
+ return candidates;
363
+ }
881
364
  rememberProviderCandidates(id, candidates, expiresAt) {
882
365
  if (candidates.length === 0)
883
366
  return;
@@ -942,7 +425,7 @@ export class FanoutTree extends DirectStream {
942
425
  addrs: candidate.addrs.map((a) => a.bytes),
943
426
  expiresAt: now + 60_000,
944
427
  }));
945
- void this._sendControl(hash, encodeProviderNotify(id.key, entries)).catch(dontThrowIfDeliveryError);
428
+ void this._sendControl(hash, this.codec.encodeProviderNotify(id.key, entries)).catch(dontThrowIfDeliveryError);
946
429
  }
947
430
  this.pruneProviderWatchersIfEmpty(id.suffixKey);
948
431
  }
@@ -1064,7 +547,7 @@ export class FanoutTree extends DirectStream {
1064
547
  return;
1065
548
  const ttlMs = ttlOverrideMs != null ? ttlOverrideMs : state.ttlMs;
1066
549
  const addrs = this.getSelfAnnounceAddrs();
1067
- const bytes = encodeProviderAnnounce(state.id.key, ttlMs, addrs);
550
+ const bytes = this.codec.encodeProviderAnnounce(state.id.key, ttlMs, addrs);
1068
551
  await this._sendControlMany(peers, bytes);
1069
552
  }
1070
553
  nextProviderReqId(suffixKey) {
@@ -1094,7 +577,7 @@ export class FanoutTree extends DirectStream {
1094
577
  const shuffleInPlace = (arr, seed32) => {
1095
578
  if (arr.length <= 1)
1096
579
  return;
1097
- if ((seed32 >>> 0) === 0) {
580
+ if (seed32 >>> 0 === 0) {
1098
581
  for (let i = arr.length - 1; i > 0; i--) {
1099
582
  const j = Math.floor(this.random() * (i + 1));
1100
583
  const tmp = arr[i];
@@ -1143,7 +626,7 @@ export class FanoutTree extends DirectStream {
1143
626
  ? await this.ensureBootstrapPeers(bootstraps, dialTimeoutMs, signal, bootstrapMaxPeers)
1144
627
  : [];
1145
628
  const perTrackerTimeout = Math.max(0, Math.floor(options.queryTimeoutMs ?? 1_000));
1146
- const overallTimeout = Math.max(0, Math.floor(options.timeoutMs ?? 5_000));
629
+ const overallTimeout = Math.max(0, Math.floor(options.timeoutMs ?? 8_000));
1147
630
  const deadlineAt = overallTimeout > 0 ? Date.now() + overallTimeout : 0;
1148
631
  const byReq = this.pendingProviderQueryBySuffixKey.get(id.suffixKey) ||
1149
632
  new Map();
@@ -1153,9 +636,13 @@ export class FanoutTree extends DirectStream {
1153
636
  const p = new Promise((resolve) => {
1154
637
  byReq.set(reqId, { resolve });
1155
638
  });
1156
- void this._sendControl(trackerHash, encodeProviderQuery(id.key, reqId, want, seed));
1157
- const remainingMs = deadlineAt > 0 ? Math.max(0, deadlineAt - Date.now()) : perTrackerTimeout;
1158
- const timeoutMs = deadlineAt > 0 ? Math.min(perTrackerTimeout, remainingMs) : perTrackerTimeout;
639
+ void this._sendControl(trackerHash, this.codec.encodeProviderQuery(id.key, reqId, want, seed));
640
+ const remainingMs = deadlineAt > 0
641
+ ? Math.max(0, deadlineAt - Date.now())
642
+ : perTrackerTimeout;
643
+ const timeoutMs = deadlineAt > 0
644
+ ? Math.min(perTrackerTimeout, remainingMs)
645
+ : perTrackerTimeout;
1159
646
  const res = await Promise.race([
1160
647
  p,
1161
648
  delay(timeoutMs, { signal }).then(() => null),
@@ -1252,7 +739,7 @@ export class FanoutTree extends DirectStream {
1252
739
  this.providerWatchesBySuffixKey.delete(id.suffixKey);
1253
740
  }
1254
741
  for (const trackerHash of watch.trackerPeers) {
1255
- void this._sendControl(trackerHash, encodeProviderUnsubscribe(id.key)).catch(dontThrowIfDeliveryError);
742
+ void this._sendControl(trackerHash, this.codec.encodeProviderUnsubscribe(id.key)).catch(dontThrowIfDeliveryError);
1256
743
  }
1257
744
  watch.trackerPeers = [];
1258
745
  };
@@ -1272,12 +759,15 @@ export class FanoutTree extends DirectStream {
1272
759
  : [];
1273
760
  watch.trackerPeers = trackerPeers;
1274
761
  for (const trackerHash of trackerPeers) {
1275
- void this._sendControl(trackerHash, encodeProviderSubscribe(id.key, watch.want, watch.ttlMs)).catch(dontThrowIfDeliveryError);
762
+ void this._sendControl(trackerHash, this.codec.encodeProviderSubscribe(id.key, watch.want, watch.ttlMs)).catch(dontThrowIfDeliveryError);
1276
763
  }
1277
764
  }
1278
765
  catch (error) {
1279
766
  if (error instanceof AbortError ||
1280
- (error && typeof error === "object" && "name" in error && error.name === "AbortError")) {
767
+ (error &&
768
+ typeof error === "object" &&
769
+ "name" in error &&
770
+ error.name === "AbortError")) {
1281
771
  return;
1282
772
  }
1283
773
  }
@@ -1337,9 +827,9 @@ export class FanoutTree extends DirectStream {
1337
827
  const neighborRepair = opts.neighborRepair === true;
1338
828
  const neighborRepairPeers = Math.max(0, Math.floor(opts.neighborRepairPeers ?? 2));
1339
829
  const neighborMeshPeers = Math.max(0, Math.floor(opts.neighborMeshPeers ?? Math.max(0, neighborRepairPeers * 2)));
1340
- const neighborAnnounceIntervalMs = Math.max(0, Math.floor(opts.neighborAnnounceIntervalMs ?? 500));
1341
- const neighborMeshRefreshIntervalMs = Math.max(0, Math.floor(opts.neighborMeshRefreshIntervalMs ?? 2_000));
1342
- const neighborHaveTtlMs = Math.max(0, Math.floor(opts.neighborHaveTtlMs ?? 5_000));
830
+ const neighborAnnounceIntervalMs = Math.max(0, Math.floor(opts.neighborAnnounceIntervalMs ?? 800));
831
+ const neighborMeshRefreshIntervalMs = Math.max(0, Math.floor(opts.neighborMeshRefreshIntervalMs ?? 10_000));
832
+ const neighborHaveTtlMs = Math.max(0, Math.floor(opts.neighborHaveTtlMs ?? 8_000));
1343
833
  const neighborRepairBudgetBps = Math.max(0, Math.floor(opts.neighborRepairBudgetBps ?? 0));
1344
834
  const neighborRepairBurstMs = Math.max(0, Math.floor(opts.neighborRepairBurstMs ?? 1_000));
1345
835
  const neighborRepairTokenCapacity = neighborRepairBudgetBps > 0 && neighborRepairBurstMs > 0
@@ -1364,6 +854,14 @@ export class FanoutTree extends DirectStream {
1364
854
  parent: undefined,
1365
855
  children: new Map(),
1366
856
  rejoinCooldownUntil: 0,
857
+ parentUpgradeCount: 0,
858
+ parentUpgradeLastAt: 0,
859
+ parentUpgradeBackoffMs: 0,
860
+ parentUpgradeBackoffUntil: 0,
861
+ parentUpgradeRetryAfterSeq: -1,
862
+ parentUpgradeStaleRootProbeRound: 0,
863
+ parentUpgradeTrackerNoCapacityUntil: 0,
864
+ parentProbeRejectBackoffMsByHash: new Map(),
1367
865
  joinedAtLeastOnce: opts.role === "root",
1368
866
  routeFromRoot: opts.role === "root" ? [this.publicKeyHash] : undefined,
1369
867
  routeByPeer: new Map(),
@@ -1413,22 +911,33 @@ export class FanoutTree extends DirectStream {
1413
911
  : undefined,
1414
912
  nextExpectedSeq: 0,
1415
913
  missingSeqs: new Set(),
914
+ repairRequestedAtBySeq: new Map(),
1416
915
  endSeqExclusive: -1,
1417
916
  lastRepairSentAt: 0,
917
+ parentRepairUnansweredStreak: 0,
1418
918
  lastParentDataAt: 0,
919
+ lastParentUpgradeActivityAt: 0,
1419
920
  receivedAnyParentData: false,
921
+ parentDataLatencySamples: 0,
922
+ parentDataLatencyEwmaMs: 0,
923
+ parentDataLatencyMaxMs: 0,
1420
924
  channelPeers: new Map(),
1421
925
  knownCandidateAddrs: new Map(),
1422
926
  lazyPeers: new Set(),
1423
927
  haveByPeer: new Map(),
1424
928
  maxSeqSeen: -1,
1425
929
  lastIHaveSentAt: 0,
930
+ cachedAnnounceTrackerPeers: undefined,
1426
931
  lastIHaveSentMaxSeq: -1,
1427
932
  pendingJoin: new Map(),
1428
933
  pendingTrackerQuery: new Map(),
934
+ pendingParentProbe: new Map(),
935
+ parentUpgradeReservationsByHash: new Map(),
936
+ parentProbeRejectUntilByHash: new Map(),
1429
937
  pendingRouteQuery: new Map(),
1430
938
  pendingUnicastAck: new Map(),
1431
939
  pendingRouteProxy: new Map(),
940
+ routeProxyByTarget: new Map(),
1432
941
  bootstrapOverride: undefined,
1433
942
  bootstrapDialTimeoutMs: 10_000,
1434
943
  bootstrapMaxPeers: 0,
@@ -1487,24 +996,133 @@ export class FanoutTree extends DirectStream {
1487
996
  }
1488
997
  ch.pendingUnicastAck.clear();
1489
998
  }
999
+ resetParentDataLatency(ch) {
1000
+ ch.parentDataLatencySamples = 0;
1001
+ ch.parentDataLatencyEwmaMs = 0;
1002
+ ch.parentDataLatencyMaxMs = 0;
1003
+ }
1004
+ noteParentDataLatency(ch, timestamp) {
1005
+ const sentAt = Number(timestamp);
1006
+ if (!Number.isFinite(sentAt) || sentAt <= 0)
1007
+ return;
1008
+ const latencyMs = Date.now() - sentAt;
1009
+ if (!Number.isFinite(latencyMs) || latencyMs < 0 || latencyMs > 60_000) {
1010
+ return;
1011
+ }
1012
+ const samples = Math.min(0xffff, ch.parentDataLatencySamples + 1);
1013
+ ch.parentDataLatencySamples = samples;
1014
+ ch.parentDataLatencyEwmaMs =
1015
+ samples <= 1
1016
+ ? latencyMs
1017
+ : ch.parentDataLatencyEwmaMs * 0.875 + latencyMs * 0.125;
1018
+ ch.parentDataLatencyMaxMs = Math.max(ch.parentDataLatencyMaxMs, latencyMs);
1019
+ }
1020
+ clearParentUpgradeGrace(ch, notifyPreviousParent = false, notifyCandidateParent = false) {
1021
+ const grace = ch.parentUpgradeGrace;
1022
+ if (!grace)
1023
+ return [];
1024
+ clearTimeout(grace.timer);
1025
+ ch.parentUpgradeGrace = undefined;
1026
+ const sends = [];
1027
+ if (notifyPreviousParent && grace.previousParent !== ch.parent) {
1028
+ sends.push(this._sendControl(grace.previousParent, this.codec.encodeLeave(ch.id.key)).catch(() => { }));
1029
+ }
1030
+ if (notifyCandidateParent && grace.candidateParent !== ch.parent) {
1031
+ sends.push(this._sendControl(grace.candidateParent, this.codec.encodeLeave(ch.id.key)).catch(() => { }));
1032
+ }
1033
+ return sends;
1034
+ }
1035
+ commitParentUpgradeGrace(ch) {
1036
+ const grace = ch.parentUpgradeGrace;
1037
+ if (!grace)
1038
+ return;
1039
+ clearTimeout(grace.timer);
1040
+ ch.parentUpgradeGrace = undefined;
1041
+ if (ch.closed || ch.parent !== grace.candidateParent)
1042
+ return;
1043
+ void this._sendControl(grace.previousParent, this.codec.encodeLeave(ch.id.key)).catch(() => { });
1044
+ }
1045
+ rollbackParentUpgradeGrace(ch) {
1046
+ const grace = ch.parentUpgradeGrace;
1047
+ if (!grace)
1048
+ return;
1049
+ clearTimeout(grace.timer);
1050
+ ch.parentUpgradeGrace = undefined;
1051
+ if (ch.closed || ch.parent !== grace.candidateParent)
1052
+ return;
1053
+ ch.parent = grace.previousParent;
1054
+ ch.level = grace.previousLevel;
1055
+ ch.routeFromRoot = grace.previousRouteFromRoot
1056
+ ? [...grace.previousRouteFromRoot]
1057
+ : undefined;
1058
+ ch.lastParentDataAt = grace.previousLastParentDataAt;
1059
+ ch.lastParentUpgradeActivityAt =
1060
+ grace.previousLastParentUpgradeActivityAt;
1061
+ ch.receivedAnyParentData = grace.previousReceivedAnyParentData;
1062
+ this.resetParentDataLatency(ch);
1063
+ this.touchPeerHint(ch, grace.previousParent);
1064
+ void this._sendControl(grace.candidateParent, this.codec.encodeLeave(ch.id.key)).catch(() => { });
1065
+ void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
1066
+ }
1067
+ startParentUpgradeGrace(ch, options) {
1068
+ if (ch.closed || options.previousParent === options.candidateParent)
1069
+ return;
1070
+ void Promise.all(this.clearParentUpgradeGrace(ch, true, true)).catch(() => { });
1071
+ const timeoutMs = Math.max(0, Math.floor(options.timeoutMs));
1072
+ if (timeoutMs <= 0) {
1073
+ void this._sendControl(options.previousParent, this.codec.encodeLeave(ch.id.key)).catch(() => { });
1074
+ return;
1075
+ }
1076
+ const timer = setTimeout(() => this.rollbackParentUpgradeGrace(ch), timeoutMs);
1077
+ ch.parentUpgradeGrace = {
1078
+ previousParent: options.previousParent,
1079
+ candidateParent: options.candidateParent,
1080
+ previousLevel: options.previousLevel,
1081
+ previousRouteFromRoot: options.previousRouteFromRoot
1082
+ ? [...options.previousRouteFromRoot]
1083
+ : undefined,
1084
+ previousLastParentDataAt: options.previousLastParentDataAt,
1085
+ previousLastParentUpgradeActivityAt: options.previousLastParentUpgradeActivityAt,
1086
+ previousReceivedAnyParentData: options.previousReceivedAnyParentData,
1087
+ timer,
1088
+ candidateFirstMessages: options.candidateFirstMessages,
1089
+ previousFirstMessages: options.previousFirstMessages,
1090
+ minCandidateFirstMessages: options.minCandidateFirstMessages,
1091
+ minCandidateAdvantageMessages: options.minCandidateAdvantageMessages,
1092
+ maxPreviousFirstMessages: options.maxPreviousFirstMessages,
1093
+ };
1094
+ }
1490
1095
  detachFromParent(ch) {
1096
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
1097
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
1098
+ }
1099
+ void Promise.all(this.clearParentUpgradeGrace(ch, true, true)).catch(() => { });
1491
1100
  ch.parent = undefined;
1492
1101
  ch.level = Number.POSITIVE_INFINITY;
1493
1102
  ch.routeFromRoot = undefined;
1494
1103
  ch.routeByPeer.clear();
1495
1104
  ch.lastParentDataAt = 0;
1105
+ ch.lastParentUpgradeActivityAt = 0;
1496
1106
  ch.receivedAnyParentData = false;
1107
+ this.resetParentDataLatency(ch);
1497
1108
  ch.pendingJoin.clear();
1109
+ ch.pendingParentProbe.clear();
1110
+ ch.parentShadow = undefined;
1111
+ ch.parentUpgradeBackoffMs = 0;
1112
+ ch.parentUpgradeBackoffUntil = 0;
1113
+ ch.parentUpgradeRetryAfterSeq = -1;
1114
+ ch.parentUpgradeStaleRootProbeRound = 0;
1115
+ ch.parentUpgradeTrackerNoCapacityUntil = 0;
1116
+ ch.parentProbeRejectUntilByHash.clear();
1117
+ ch.parentProbeRejectBackoffMsByHash.clear();
1498
1118
  ch.pendingRouteQuery.clear();
1499
1119
  this.abortPendingUnicastAcks(ch, new AbortError("fanout channel detached"));
1500
- for (const pending of ch.pendingRouteProxy.values()) {
1501
- clearTimeout(pending.timer);
1502
- }
1503
- ch.pendingRouteProxy.clear();
1120
+ this.clearRouteProxies(ch);
1504
1121
  }
1505
1122
  onPeerDisconnectedFromUnderlay(peerHash) {
1506
1123
  if (!peerHash)
1507
1124
  return;
1125
+ this.joinTimeoutStreakByPeer.delete(peerHash);
1508
1126
  // Detach from a disconnected parent immediately, so children can rejoin.
1509
1127
  // This is more reliable than polling `getConnections()` because the underlay
1510
1128
  // can flap/reconnect faster than the join loop cadence.
@@ -1558,19 +1176,24 @@ export class FanoutTree extends DirectStream {
1558
1176
  const kickChildren = options?.kickChildren !== false;
1559
1177
  const pendingSends = [];
1560
1178
  if (notifyParent && !ch.isRoot && ch.parent) {
1561
- pendingSends.push(this._sendControl(ch.parent, encodeLeave(ch.id.key)).catch(() => { }));
1179
+ pendingSends.push(this._sendControl(ch.parent, this.codec.encodeLeave(ch.id.key)).catch(() => { }));
1562
1180
  }
1181
+ pendingSends.push(...this.clearParentUpgradeGrace(ch, notifyParent && !ch.isRoot, false));
1563
1182
  if (kickChildren && ch.children.size > 0) {
1564
1183
  pendingSends.push(this.kickChildren(ch).catch(() => { }));
1565
1184
  }
1566
1185
  ch.pendingJoin.clear();
1567
1186
  ch.pendingTrackerQuery.clear();
1187
+ ch.pendingParentProbe.clear();
1188
+ ch.parentProbeRejectUntilByHash.clear();
1189
+ ch.parentProbeRejectBackoffMsByHash.clear();
1190
+ ch.parentShadow = undefined;
1191
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
1192
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
1193
+ }
1568
1194
  ch.pendingRouteQuery.clear();
1569
1195
  this.abortPendingUnicastAcks(ch, new AbortError("fanout channel closed"));
1570
- for (const pending of ch.pendingRouteProxy.values()) {
1571
- clearTimeout(pending.timer);
1572
- }
1573
- ch.pendingRouteProxy.clear();
1196
+ this.clearRouteProxies(ch);
1574
1197
  ch.parent = undefined;
1575
1198
  ch.children.clear();
1576
1199
  ch.lazyPeers.clear();
@@ -1603,6 +1226,11 @@ export class FanoutTree extends DirectStream {
1603
1226
  routeCacheMaxEntries: ch.routeCacheMaxEntries,
1604
1227
  };
1605
1228
  }
1229
+ /**
1230
+ * Returns diagnostic counters for tests and simulation harnesses.
1231
+ *
1232
+ * @internal
1233
+ */
1606
1234
  getChannelMetrics(topic, root) {
1607
1235
  const id = this.getChannelId(topic, root);
1608
1236
  return this.getMetricsForSuffixKey(id.suffixKey);
@@ -1662,11 +1290,23 @@ export class FanoutTree extends DirectStream {
1662
1290
  if (!ch.joinedOnce)
1663
1291
  ch.joinedOnce = createDeferred();
1664
1292
  if (!ch.joinLoop) {
1665
- ch.joinLoop = this._joinLoop(ch, joinOpts).catch((err) => {
1293
+ const joinedOnce = ch.joinedOnce;
1294
+ const joinLoop = this._joinLoop(ch, joinOpts)
1295
+ .catch((err) => {
1666
1296
  // Surface join errors to the caller without crashing the process
1667
1297
  // via an unhandled rejection (joinLoop is not generally awaited).
1668
- ch.joinedOnce?.reject(err);
1298
+ if (ch.joinLoop === joinLoop)
1299
+ ch.joinLoop = undefined;
1300
+ if (ch.joinedOnce === joinedOnce && !ch.joinedAtLeastOnce) {
1301
+ ch.joinedOnce = undefined;
1302
+ }
1303
+ joinedOnce.reject(err);
1304
+ })
1305
+ .finally(() => {
1306
+ if (ch.joinLoop === joinLoop)
1307
+ ch.joinLoop = undefined;
1669
1308
  });
1309
+ ch.joinLoop = joinLoop;
1670
1310
  }
1671
1311
  return ch.joinedOnce.promise;
1672
1312
  }
@@ -1706,7 +1346,9 @@ export class FanoutTree extends DirectStream {
1706
1346
  updatedAt: Date.now(),
1707
1347
  };
1708
1348
  }
1709
- const route = this.getCachedRoute(ch, targetHash);
1349
+ // Speculative probe: a miss here says nothing about cache quality (the
1350
+ // caller falls through to resolveRouteToken, which counts its own miss).
1351
+ const route = this.getCachedRoute(ch, targetHash, { recordMiss: false });
1710
1352
  if (!route)
1711
1353
  return undefined;
1712
1354
  const entry = ch.routeByPeer.get(targetHash);
@@ -1751,8 +1393,7 @@ export class FanoutTree extends DirectStream {
1751
1393
  ch.knownCandidateAddrs.delete(hash);
1752
1394
  ch.knownCandidateAddrs.set(hash, limited);
1753
1395
  while (ch.knownCandidateAddrs.size > KNOWN_CANDIDATE_ADDRS_MAX_ENTRIES) {
1754
- const oldest = ch.knownCandidateAddrs.keys().next()
1755
- .value;
1396
+ const oldest = ch.knownCandidateAddrs.keys().next().value;
1756
1397
  if (!oldest)
1757
1398
  break;
1758
1399
  ch.knownCandidateAddrs.delete(oldest);
@@ -1833,23 +1474,27 @@ export class FanoutTree extends DirectStream {
1833
1474
  ch.metrics.routeCacheEvictions += 1;
1834
1475
  }
1835
1476
  }
1836
- getCachedRoute(ch, targetHash) {
1477
+ getCachedRoute(ch, targetHash, opts) {
1478
+ const recordMiss = opts?.recordMiss !== false;
1837
1479
  this.pruneRouteCache(ch);
1838
1480
  const entry = ch.routeByPeer.get(targetHash);
1839
1481
  if (!entry) {
1840
- ch.metrics.routeCacheMisses += 1;
1482
+ if (recordMiss)
1483
+ ch.metrics.routeCacheMisses += 1;
1841
1484
  return undefined;
1842
1485
  }
1843
1486
  const now = Date.now();
1844
1487
  if (ch.routeCacheTtlMs > 0 && now - entry.updatedAt > ch.routeCacheTtlMs) {
1845
1488
  ch.routeByPeer.delete(targetHash);
1846
- ch.metrics.routeCacheMisses += 1;
1489
+ if (recordMiss)
1490
+ ch.metrics.routeCacheMisses += 1;
1847
1491
  ch.metrics.routeCacheExpirations += 1;
1848
1492
  return undefined;
1849
1493
  }
1850
1494
  if (!this.isRouteValidForChannel(ch, entry.route)) {
1851
1495
  ch.routeByPeer.delete(targetHash);
1852
- ch.metrics.routeCacheMisses += 1;
1496
+ if (recordMiss)
1497
+ ch.metrics.routeCacheMisses += 1;
1853
1498
  return undefined;
1854
1499
  }
1855
1500
  // LRU touch
@@ -1883,14 +1528,92 @@ export class FanoutTree extends DirectStream {
1883
1528
  if (!proxy)
1884
1529
  return;
1885
1530
  ch.pendingRouteProxy.delete(proxyReqId);
1531
+ const targetKey = `${proxy.direction}:${proxy.targetHash}`;
1532
+ if (ch.routeProxyByTarget.get(targetKey) === proxyReqId) {
1533
+ ch.routeProxyByTarget.delete(targetKey);
1534
+ }
1886
1535
  clearTimeout(proxy.timer);
1536
+ const reply = (requester, downstreamReqId) => {
1537
+ void this._sendControl(requester, this.codec.encodeRouteReply(ch.id.key, downstreamReqId, route)).catch(() => { });
1538
+ };
1887
1539
  if (proxy.localResolve) {
1888
1540
  proxy.localResolve(route);
1889
- return;
1890
1541
  }
1891
- void this._sendControl(proxy.requester, encodeRouteReply(ch.id.key, proxy.downstreamReqId, route)).catch(() => { });
1542
+ else {
1543
+ reply(proxy.requester, proxy.downstreamReqId);
1544
+ }
1545
+ if (proxy.waiters) {
1546
+ for (const w of proxy.waiters) {
1547
+ if ("localResolve" in w) {
1548
+ w.localResolve(route);
1549
+ }
1550
+ else {
1551
+ reply(w.requester, w.downstreamReqId);
1552
+ }
1553
+ }
1554
+ }
1555
+ }
1556
+ /**
1557
+ * Coalesce onto an in-flight same-target/same-direction proxy search, if one
1558
+ * exists. Returns true when the requester was attached as a waiter (or
1559
+ * answered empty on waiter overflow) and no new search should start.
1560
+ */
1561
+ coalesceRouteProxy(ch, targetHash, direction, waiter) {
1562
+ const targetKey = `${direction}:${targetHash}`;
1563
+ const existingId = ch.routeProxyByTarget.get(targetKey);
1564
+ if (existingId == null)
1565
+ return false;
1566
+ const existing = ch.pendingRouteProxy.get(existingId);
1567
+ if (!existing) {
1568
+ ch.routeProxyByTarget.delete(targetKey);
1569
+ return false;
1570
+ }
1571
+ if ((existing.waiters?.length ?? 0) >= ROUTE_PROXY_MAX_WAITERS) {
1572
+ ch.metrics.routeProxyRejected += 1;
1573
+ if ("localResolve" in waiter) {
1574
+ waiter.localResolve(undefined);
1575
+ }
1576
+ else {
1577
+ void this._sendControl(waiter.requester, this.codec.encodeRouteReply(ch.id.key, waiter.downstreamReqId)).catch(() => { });
1578
+ }
1579
+ return true;
1580
+ }
1581
+ (existing.waiters ??= []).push(waiter);
1582
+ ch.metrics.routeProxyCoalesced += 1;
1583
+ return true;
1584
+ }
1585
+ /**
1586
+ * Drop all in-flight route-proxy state for a channel (detach/kick/close).
1587
+ * Local resolvers are settled with `undefined` so root-origin
1588
+ * `resolveRouteToken()` callers never hang; remote requesters time out on
1589
+ * their own deadlines, as before.
1590
+ */
1591
+ clearRouteProxies(ch) {
1592
+ for (const pending of ch.pendingRouteProxy.values()) {
1593
+ clearTimeout(pending.timer);
1594
+ pending.localResolve?.(undefined);
1595
+ if (pending.waiters) {
1596
+ for (const w of pending.waiters) {
1597
+ if ("localResolve" in w)
1598
+ w.localResolve(undefined);
1599
+ }
1600
+ }
1601
+ }
1602
+ ch.pendingRouteProxy.clear();
1603
+ ch.routeProxyByTarget.clear();
1892
1604
  }
1893
1605
  proxyRouteQuery(ch, requester, downstreamReqId, targetHash, candidates, timeoutMs = ROUTE_PROXY_TIMEOUT_MS) {
1606
+ // Direction matters for coalescing correctness: an upstream lookup and the
1607
+ // root's downstream flood for the same target may legitimately transit the
1608
+ // same node concurrently (e.g. the target lives in the requester's own
1609
+ // branch), and merging them would make them wait on each other.
1610
+ const direction = candidates.length === 1 && candidates[0] === ch.parent ? "up" : "down";
1611
+ if (this.coalesceRouteProxy(ch, targetHash, direction, {
1612
+ requester,
1613
+ downstreamReqId,
1614
+ })) {
1615
+ return;
1616
+ }
1894
1617
  const unique = [];
1895
1618
  const seen = new Set();
1896
1619
  for (const candidate of candidates) {
@@ -1903,7 +1626,12 @@ export class FanoutTree extends DirectStream {
1903
1626
  unique.push(candidate);
1904
1627
  }
1905
1628
  if (unique.length === 0) {
1906
- void this._sendControl(requester, encodeRouteReply(ch.id.key, downstreamReqId)).catch(() => { });
1629
+ void this._sendControl(requester, this.codec.encodeRouteReply(ch.id.key, downstreamReqId)).catch(() => { });
1630
+ return;
1631
+ }
1632
+ if (ch.pendingRouteProxy.size >= ROUTE_PROXY_MAX_PENDING) {
1633
+ ch.metrics.routeProxyRejected += 1;
1634
+ void this._sendControl(requester, this.codec.encodeRouteReply(ch.id.key, downstreamReqId)).catch(() => { });
1907
1635
  return;
1908
1636
  }
1909
1637
  ch.metrics.routeProxyQueries += 1;
@@ -1918,9 +1646,11 @@ export class FanoutTree extends DirectStream {
1918
1646
  downstreamReqId,
1919
1647
  timer,
1920
1648
  expectedReplies: new Set(unique),
1649
+ targetHash,
1650
+ direction,
1921
1651
  });
1922
- void this._sendControlMany(unique, encodeRouteQuery(ch.id.key, proxyReqId, targetHash))
1923
- .catch(() => {
1652
+ ch.routeProxyByTarget.set(`${direction}:${targetHash}`, proxyReqId);
1653
+ void this._sendControlMany(unique, this.codec.encodeRouteQuery(ch.id.key, proxyReqId, targetHash)).catch(() => {
1924
1654
  this.completeRouteProxy(ch, proxyReqId);
1925
1655
  });
1926
1656
  }
@@ -1950,14 +1680,47 @@ export class FanoutTree extends DirectStream {
1950
1680
  const timeoutMs = Math.max(1, Math.floor(options?.timeoutMs ?? 3_000));
1951
1681
  return await new Promise((resolve, reject) => {
1952
1682
  let settled = false;
1683
+ let proxyReqId;
1953
1684
  const onAbort = () => {
1954
1685
  if (settled)
1955
1686
  return;
1956
1687
  settled = true;
1957
- ch.pendingRouteProxy.delete(proxyReqId);
1958
- clearTimeout(timer);
1688
+ clearTimeout(backstop);
1689
+ if (proxyReqId != null) {
1690
+ // Tear the search down only if nobody else coalesced onto it;
1691
+ // otherwise let it finish for the waiters (our localResolve
1692
+ // is a no-op once settled).
1693
+ const entry = ch.pendingRouteProxy.get(proxyReqId);
1694
+ if (entry && (!entry.waiters || entry.waiters.length === 0)) {
1695
+ this.completeRouteProxy(ch, proxyReqId);
1696
+ }
1697
+ }
1959
1698
  reject(new AbortError());
1960
1699
  };
1700
+ const localResolve = (route) => {
1701
+ if (settled)
1702
+ return;
1703
+ settled = true;
1704
+ clearTimeout(backstop);
1705
+ if (options?.signal) {
1706
+ options.signal.removeEventListener("abort", onAbort);
1707
+ }
1708
+ if (this.isRouteValidForChannel(ch, route)) {
1709
+ this.cacheRoute(ch, route);
1710
+ resolve([...route]);
1711
+ return;
1712
+ }
1713
+ resolve(undefined);
1714
+ };
1715
+ // Per-caller timeout: when coalesced, the shared in-flight search
1716
+ // may outlive this caller's own deadline.
1717
+ const backstop = setTimeout(() => localResolve(undefined), timeoutMs);
1718
+ if (options?.signal) {
1719
+ options.signal.addEventListener("abort", onAbort, { once: true });
1720
+ }
1721
+ if (this.coalesceRouteProxy(ch, targetHash, "down", { localResolve })) {
1722
+ return;
1723
+ }
1961
1724
  const unique = [];
1962
1725
  const seen = new Set();
1963
1726
  for (const candidate of candidates) {
@@ -1970,42 +1733,32 @@ export class FanoutTree extends DirectStream {
1970
1733
  unique.push(candidate);
1971
1734
  }
1972
1735
  if (unique.length === 0) {
1973
- resolve(undefined);
1736
+ localResolve(undefined);
1737
+ return;
1738
+ }
1739
+ if (ch.pendingRouteProxy.size >= ROUTE_PROXY_MAX_PENDING) {
1740
+ ch.metrics.routeProxyRejected += 1;
1741
+ localResolve(undefined);
1974
1742
  return;
1975
1743
  }
1976
1744
  ch.metrics.routeProxyQueries += 1;
1977
1745
  ch.metrics.routeProxyFanout += unique.length;
1978
- const proxyReqId = this.nextReqId(ch);
1746
+ proxyReqId = this.nextReqId(ch);
1979
1747
  const timer = setTimeout(() => {
1980
1748
  ch.metrics.routeProxyTimeouts += 1;
1981
1749
  this.completeRouteProxy(ch, proxyReqId);
1982
1750
  }, timeoutMs);
1983
- if (options?.signal) {
1984
- options.signal.addEventListener("abort", onAbort, { once: true });
1985
- }
1986
1751
  ch.pendingRouteProxy.set(proxyReqId, {
1987
1752
  requester: this.publicKeyHash,
1988
1753
  downstreamReqId: 0,
1989
1754
  timer,
1990
1755
  expectedReplies: new Set(unique),
1991
- localResolve: (route) => {
1992
- if (settled)
1993
- return;
1994
- settled = true;
1995
- clearTimeout(timer);
1996
- if (options?.signal) {
1997
- options.signal.removeEventListener("abort", onAbort);
1998
- }
1999
- if (this.isRouteValidForChannel(ch, route)) {
2000
- this.cacheRoute(ch, route);
2001
- resolve([...route]);
2002
- return;
2003
- }
2004
- resolve(undefined);
2005
- },
1756
+ targetHash,
1757
+ direction: "down",
1758
+ localResolve,
2006
1759
  });
2007
- void this._sendControlMany(unique, encodeRouteQuery(ch.id.key, proxyReqId, targetHash))
2008
- .catch(() => {
1760
+ ch.routeProxyByTarget.set(`down:${targetHash}`, proxyReqId);
1761
+ void this._sendControlMany(unique, this.codec.encodeRouteQuery(ch.id.key, proxyReqId, targetHash)).catch(() => {
2009
1762
  this.completeRouteProxy(ch, proxyReqId);
2010
1763
  });
2011
1764
  });
@@ -2052,8 +1805,7 @@ export class FanoutTree extends DirectStream {
2052
1805
  resolve(undefined);
2053
1806
  },
2054
1807
  });
2055
- void this._sendControl(ch.parent, encodeRouteQuery(ch.id.key, reqId, targetHash))
2056
- .catch((error) => {
1808
+ void this._sendControl(ch.parent, this.codec.encodeRouteQuery(ch.id.key, reqId, targetHash)).catch((error) => {
2057
1809
  if (settled)
2058
1810
  return;
2059
1811
  settled = true;
@@ -2101,7 +1853,7 @@ export class FanoutTree extends DirectStream {
2101
1853
  throw new Error("Unicast route token target mismatch");
2102
1854
  }
2103
1855
  if (target === this.publicKeyHash) {
2104
- const wire = encodeUnicast(ch.id.key, toRoute, payload);
1856
+ const wire = this.codec.encodeUnicast(ch.id.key, toRoute, payload);
2105
1857
  const message = await this.createMessage(wire, {
2106
1858
  mode: new AnyWhere(),
2107
1859
  priority: CONTROL_PRIORITY,
@@ -2137,7 +1889,7 @@ export class FanoutTree extends DirectStream {
2137
1889
  if (replyRoute[replyRoute.length - 1] !== selfHash) {
2138
1890
  throw new Error("Cannot unicast with ACK: self route token target mismatch");
2139
1891
  }
2140
- const data = encodeUnicast(ch.id.key, toRoute, payload, {
1892
+ const data = this.codec.encodeUnicast(ch.id.key, toRoute, payload, {
2141
1893
  ackToken,
2142
1894
  replyRoute,
2143
1895
  });
@@ -2231,7 +1983,7 @@ export class FanoutTree extends DirectStream {
2231
1983
  if (toRoute[0] !== ch.id.root) {
2232
1984
  throw new Error("Unicast route token root mismatch");
2233
1985
  }
2234
- const data = encodeUnicast(ch.id.key, toRoute, payload);
1986
+ const data = this.codec.encodeUnicast(ch.id.key, toRoute, payload);
2235
1987
  if (ch.isRoot) {
2236
1988
  const target = toRoute[toRoute.length - 1];
2237
1989
  if (target === this.publicKeyHash) {
@@ -2314,7 +2066,7 @@ export class FanoutTree extends DirectStream {
2314
2066
  if (!ch.parent) {
2315
2067
  throw new Error(`Cannot proxy publish while not attached to a parent (topic=${topic} root=${root} self=${this.publicKeyHash})`);
2316
2068
  }
2317
- await this._sendControl(ch.parent, encodePublishProxy(ch.id.key, payload));
2069
+ await this._sendControl(ch.parent, this.codec.encodePublishProxy(ch.id.key, payload));
2318
2070
  }
2319
2071
  async publishToChannelMaybe(topic, root, payload) {
2320
2072
  const id = this.getChannelId(topic, root);
@@ -2412,7 +2164,8 @@ export class FanoutTree extends DirectStream {
2412
2164
  return;
2413
2165
  if (!ch.isRoot)
2414
2166
  return;
2415
- await this._sendControlMany([...ch.children.keys()], encodeEnd(ch.id.key, lastSeqExclusive));
2167
+ ch.endSeqExclusive = Math.max(ch.endSeqExclusive, lastSeqExclusive);
2168
+ await this._sendControlMany([...ch.children.keys()], this.codec.encodeEnd(ch.id.key, lastSeqExclusive));
2416
2169
  }
2417
2170
  makeDataId(ch, seq) {
2418
2171
  const id = new Uint8Array(32);
@@ -2497,6 +2250,14 @@ export class FanoutTree extends DirectStream {
2497
2250
  m.trackerFeedbackSent += transmissions;
2498
2251
  m.controlBytesSentTracker += sentBytes;
2499
2252
  break;
2253
+ case MSG_PARENT_PROBE_REQ:
2254
+ m.parentProbeReqSent += transmissions;
2255
+ m.controlBytesSentJoin += sentBytes;
2256
+ break;
2257
+ case MSG_PARENT_PROBE_REPLY:
2258
+ m.parentProbeReplySent += transmissions;
2259
+ m.controlBytesSentJoin += sentBytes;
2260
+ break;
2500
2261
  case MSG_PROVIDER_ANNOUNCE:
2501
2262
  case MSG_PROVIDER_QUERY:
2502
2263
  case MSG_PROVIDER_REPLY:
@@ -2562,6 +2323,14 @@ export class FanoutTree extends DirectStream {
2562
2323
  m.trackerFeedbackReceived += 1;
2563
2324
  m.controlBytesReceivedTracker += bytesReceived;
2564
2325
  break;
2326
+ case MSG_PARENT_PROBE_REQ:
2327
+ m.parentProbeReqReceived += 1;
2328
+ m.controlBytesReceivedJoin += bytesReceived;
2329
+ break;
2330
+ case MSG_PARENT_PROBE_REPLY:
2331
+ m.parentProbeReplyReceived += 1;
2332
+ m.controlBytesReceivedJoin += bytesReceived;
2333
+ break;
2565
2334
  case MSG_PROVIDER_ANNOUNCE:
2566
2335
  case MSG_PROVIDER_QUERY:
2567
2336
  case MSG_PROVIDER_REPLY:
@@ -2590,6 +2359,38 @@ export class FanoutTree extends DirectStream {
2590
2359
  return;
2591
2360
  return ch.cachePayloads[idx];
2592
2361
  }
2362
+ noteJoinResponse(peerHash) {
2363
+ this.joinTimeoutStreakByPeer.delete(peerHash);
2364
+ }
2365
+ noteJoinTimeout(ch, peerHash) {
2366
+ const streak = (this.joinTimeoutStreakByPeer.get(peerHash) ?? 0) + 1;
2367
+ if (streak < JOIN_TIMEOUT_RESET_STREAK_THRESHOLD) {
2368
+ this.joinTimeoutStreakByPeer.set(peerHash, streak);
2369
+ return;
2370
+ }
2371
+ this.joinTimeoutStreakByPeer.delete(peerHash);
2372
+ const now = Date.now();
2373
+ for (const [hash, until] of this.joinResetCooldownUntilByPeer) {
2374
+ if (until <= now)
2375
+ this.joinResetCooldownUntilByPeer.delete(hash);
2376
+ }
2377
+ const resetCooldownUntil = this.joinResetCooldownUntilByPeer.get(peerHash) ?? 0;
2378
+ if (resetCooldownUntil > now)
2379
+ return;
2380
+ const stream = this.peers.get(peerHash);
2381
+ if (!stream)
2382
+ return;
2383
+ this.joinResetCooldownUntilByPeer.set(peerHash, now + JOIN_TIMEOUT_RESET_COOLDOWN_MS);
2384
+ ch.metrics.joinPeerResets += 1;
2385
+ try {
2386
+ void this.components.connectionManager
2387
+ .closeConnections(stream.peerId)
2388
+ .catch(() => { });
2389
+ }
2390
+ catch {
2391
+ // Best-effort reset. The join loop keeps retrying other candidates.
2392
+ }
2393
+ }
2593
2394
  async _sendControl(to, bytes) {
2594
2395
  const stream = this.peers.get(to);
2595
2396
  if (!stream)
@@ -2603,18 +2404,18 @@ export class FanoutTree extends DirectStream {
2603
2404
  }
2604
2405
  async _sendControlMany(to, bytes) {
2605
2406
  if (to.length === 0)
2606
- return;
2407
+ return true;
2607
2408
  const streams = to
2608
2409
  .map((t) => this.peers.get(t))
2609
2410
  .filter((s) => Boolean(s));
2610
2411
  if (streams.length === 0)
2611
- return;
2412
+ return false;
2612
2413
  this.recordControlSend(bytes, streams.length);
2613
2414
  const message = await this.createMessage(bytes, {
2614
2415
  mode: new AnyWhere(),
2615
2416
  priority: CONTROL_PRIORITY,
2616
2417
  });
2617
- await this.publishMessageMaybe(this.publicKey, message, streams);
2418
+ return this.publishMessageMaybe(this.publicKey, message, streams);
2618
2419
  }
2619
2420
  refillUploadTokens(ch, now = Date.now()) {
2620
2421
  if (ch.uploadLimitBps <= 0)
@@ -2636,7 +2437,8 @@ export class FanoutTree extends DirectStream {
2636
2437
  if (elapsedMs <= 0)
2637
2438
  return;
2638
2439
  ch.neighborRepairLastRefillAt = now;
2639
- ch.neighborRepairTokens = Math.min(ch.neighborRepairTokenCapacity, ch.neighborRepairTokens + (elapsedMs * ch.neighborRepairBudgetBps) / 1_000);
2440
+ ch.neighborRepairTokens = Math.min(ch.neighborRepairTokenCapacity, ch.neighborRepairTokens +
2441
+ (elapsedMs * ch.neighborRepairBudgetBps) / 1_000);
2640
2442
  }
2641
2443
  takeIngressBudget(ch, kind, fromHash, costBytes, now = Date.now()) {
2642
2444
  if (!fromHash)
@@ -2673,7 +2475,7 @@ export class FanoutTree extends DirectStream {
2673
2475
  async _sendData(ch, to, seq, payload) {
2674
2476
  this.markCached(ch, seq, payload);
2675
2477
  ch.maxSeqSeen = Math.max(ch.maxSeqSeen, seq);
2676
- const framed = encodeData(payload);
2478
+ const framed = this.codec.encodeData(payload);
2677
2479
  const message = await this.createMessage(framed, {
2678
2480
  mode: new AnyWhere(),
2679
2481
  priority: DATA_PRIORITY,
@@ -2742,14 +2544,12 @@ export class FanoutTree extends DirectStream {
2742
2544
  });
2743
2545
  const kickCount = Math.min(OVERLOAD_KICK_MAX_PER_EVENT, droppedCandidates.length);
2744
2546
  if (kickCount > 0) {
2745
- const toKick = droppedCandidates.slice(0, kickCount).map((c) => c.hash);
2746
- for (const h of toKick) {
2747
- ch.children.delete(h);
2748
- ch.dataWriteFailStreakByChild.delete(h);
2749
- }
2547
+ const toKick = droppedCandidates
2548
+ .slice(0, kickCount)
2549
+ .map((c) => c.hash);
2750
2550
  ch.overloadStreak = 0;
2751
2551
  ch.lastOverloadKickAt = now;
2752
- void this._sendControlMany(toKick, encodeKick(ch.id.key));
2552
+ void this.kickChildHashes(ch, toKick).catch(() => { });
2753
2553
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
2754
2554
  }
2755
2555
  }
@@ -2778,13 +2578,6 @@ export class FanoutTree extends DirectStream {
2778
2578
  const stream = c.stream;
2779
2579
  if (!stream.isWritable) {
2780
2580
  writeDrops += 1;
2781
- if (ch.children.has(c.hash)) {
2782
- const streak = (ch.dataWriteFailStreakByChild.get(c.hash) ?? 0) + 1;
2783
- ch.dataWriteFailStreakByChild.set(c.hash, streak);
2784
- if (streak >= DATA_WRITE_FAIL_KICK_STREAK_THRESHOLD) {
2785
- writeFailKickCandidates.push(c.hash);
2786
- }
2787
- }
2788
2581
  continue;
2789
2582
  }
2790
2583
  try {
@@ -2831,12 +2624,8 @@ export class FanoutTree extends DirectStream {
2831
2624
  const kickCount = Math.min(DATA_WRITE_FAIL_KICK_MAX_PER_EVENT, unique.length);
2832
2625
  if (kickCount > 0) {
2833
2626
  const toKick = unique.slice(0, kickCount);
2834
- for (const h of toKick) {
2835
- ch.children.delete(h);
2836
- ch.dataWriteFailStreakByChild.delete(h);
2837
- }
2838
2627
  ch.lastDataWriteFailKickAt = now;
2839
- void this._sendControlMany(toKick, encodeKick(ch.id.key));
2628
+ void this.kickChildHashes(ch, toKick, { resetPeerConnections: true }).catch(() => { });
2840
2629
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
2841
2630
  }
2842
2631
  }
@@ -2915,14 +2704,12 @@ export class FanoutTree extends DirectStream {
2915
2704
  });
2916
2705
  const kickCount = Math.min(OVERLOAD_KICK_MAX_PER_EVENT, droppedCandidates.length);
2917
2706
  if (kickCount > 0) {
2918
- const toKick = droppedCandidates.slice(0, kickCount).map((c) => c.hash);
2919
- for (const h of toKick) {
2920
- ch.children.delete(h);
2921
- ch.dataWriteFailStreakByChild.delete(h);
2922
- }
2707
+ const toKick = droppedCandidates
2708
+ .slice(0, kickCount)
2709
+ .map((c) => c.hash);
2923
2710
  ch.overloadStreak = 0;
2924
2711
  ch.lastOverloadKickAt = now;
2925
- void this._sendControlMany(toKick, encodeKick(ch.id.key));
2712
+ void this.kickChildHashes(ch, toKick).catch(() => { });
2926
2713
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
2927
2714
  }
2928
2715
  }
@@ -2940,11 +2727,6 @@ export class FanoutTree extends DirectStream {
2940
2727
  const stream = c.stream;
2941
2728
  if (!stream.isWritable) {
2942
2729
  writeDrops += 1;
2943
- const streak = (ch.dataWriteFailStreakByChild.get(c.hash) ?? 0) + 1;
2944
- ch.dataWriteFailStreakByChild.set(c.hash, streak);
2945
- if (streak >= DATA_WRITE_FAIL_KICK_STREAK_THRESHOLD) {
2946
- writeFailKickCandidates.push(c.hash);
2947
- }
2948
2730
  continue;
2949
2731
  }
2950
2732
  try {
@@ -2989,12 +2771,8 @@ export class FanoutTree extends DirectStream {
2989
2771
  const kickCount = Math.min(DATA_WRITE_FAIL_KICK_MAX_PER_EVENT, unique.length);
2990
2772
  if (kickCount > 0) {
2991
2773
  const toKick = unique.slice(0, kickCount);
2992
- for (const h of toKick) {
2993
- ch.children.delete(h);
2994
- ch.dataWriteFailStreakByChild.delete(h);
2995
- }
2996
2774
  ch.lastDataWriteFailKickAt = now;
2997
- void this._sendControlMany(toKick, encodeKick(ch.id.key));
2775
+ void this.kickChildHashes(ch, toKick, { resetPeerConnections: true }).catch(() => { });
2998
2776
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
2999
2777
  }
3000
2778
  }
@@ -3002,6 +2780,7 @@ export class FanoutTree extends DirectStream {
3002
2780
  noteReceivedSeq(ch, fromHash, seq) {
3003
2781
  if (!ch.repairEnabled)
3004
2782
  return;
2783
+ ch.repairRequestedAtBySeq.delete(seq);
3005
2784
  if (!ch.parent || fromHash !== ch.parent) {
3006
2785
  // Neighbor-assisted repair may deliver payload from a peer other than the
3007
2786
  // current parent; treat it as a "hole fill" only.
@@ -3025,7 +2804,9 @@ export class FanoutTree extends DirectStream {
3025
2804
  }
3026
2805
  if (seq >= ch.nextExpectedSeq) {
3027
2806
  const maxBackfill = Math.max(0, ch.repairMaxBackfillMessages);
3028
- const start = maxBackfill > 0 ? Math.max(ch.nextExpectedSeq, Math.max(0, seq - maxBackfill)) : ch.nextExpectedSeq;
2807
+ const start = maxBackfill > 0
2808
+ ? Math.max(ch.nextExpectedSeq, Math.max(0, seq - maxBackfill))
2809
+ : ch.nextExpectedSeq;
3029
2810
  for (let s = start; s < seq; s++)
3030
2811
  ch.missingSeqs.add(s);
3031
2812
  ch.nextExpectedSeq = seq + 1;
@@ -3072,21 +2853,32 @@ export class FanoutTree extends DirectStream {
3072
2853
  ch.missingSeqs.delete(s);
3073
2854
  }
3074
2855
  }
2856
+ for (const s of ch.repairRequestedAtBySeq.keys()) {
2857
+ if (!ch.missingSeqs.has(s))
2858
+ ch.repairRequestedAtBySeq.delete(s);
2859
+ }
3075
2860
  if (ch.missingSeqs.size === 0)
3076
2861
  return false;
3077
- if (ch.repairIntervalMs > 0 && now - ch.lastRepairSentAt < ch.repairIntervalMs) {
2862
+ if (ch.repairIntervalMs > 0 &&
2863
+ now - ch.lastRepairSentAt < ch.repairIntervalMs) {
3078
2864
  return false;
3079
2865
  }
3080
- ch.lastRepairSentAt = now;
3081
- const missing = [...ch.missingSeqs].sort((a, b) => a - b);
2866
+ const repairRetryMs = Math.max(REPAIR_RETRY_MIN_MS, ch.repairIntervalMs * REPAIR_RETRY_INTERVAL_FACTOR);
2867
+ const missing = [...ch.missingSeqs]
2868
+ .filter((s) => now - (ch.repairRequestedAtBySeq.get(s) ?? 0) >= repairRetryMs)
2869
+ .sort((a, b) => a - b);
3082
2870
  const count = Math.min(ch.repairMaxPerReq, missing.length, 255);
3083
2871
  if (count <= 0)
3084
2872
  return false;
2873
+ ch.lastRepairSentAt = now;
3085
2874
  const reqId = (this.random() * 0xffffffff) >>> 0;
3086
2875
  const slice = missing.slice(0, count);
2876
+ for (const s of slice)
2877
+ ch.repairRequestedAtBySeq.set(s, now);
3087
2878
  let sent = false;
3088
2879
  if (ch.parent) {
3089
- await this._sendControl(ch.parent, encodeRepairReq(ch.id.key, reqId, slice));
2880
+ await this._sendControl(ch.parent, this.codec.encodeRepairReq(ch.id.key, reqId, slice));
2881
+ ch.parentRepairUnansweredStreak += 1;
3090
2882
  sent = true;
3091
2883
  }
3092
2884
  if (ch.neighborRepair && ch.neighborRepairPeers > 0) {
@@ -3136,35 +2928,25 @@ export class FanoutTree extends DirectStream {
3136
2928
  updatedAt,
3137
2929
  };
3138
2930
  });
3139
- scored.sort((a, b) => {
2931
+ const viable = scored.filter((s) => s.coverage > 0);
2932
+ viable.sort((a, b) => {
3140
2933
  if (a.coverage !== b.coverage)
3141
2934
  return b.coverage - a.coverage;
3142
- if (a.coverage > 0) {
3143
- if (a.successRate !== b.successRate)
3144
- return b.successRate - a.successRate;
3145
- if (a.updatedAt !== b.updatedAt)
3146
- return b.updatedAt - a.updatedAt;
3147
- const al = ch.lazyPeers.has(a.peerHash) ? 1 : 0;
3148
- const bl = ch.lazyPeers.has(b.peerHash) ? 1 : 0;
3149
- if (al !== bl)
3150
- return bl - al;
3151
- }
3152
- else {
3153
- const al = ch.lazyPeers.has(a.peerHash) ? 1 : 0;
3154
- const bl = ch.lazyPeers.has(b.peerHash) ? 1 : 0;
3155
- if (al !== bl)
3156
- return bl - al;
3157
- if (a.successRate !== b.successRate)
3158
- return b.successRate - a.successRate;
3159
- if (a.updatedAt !== b.updatedAt)
3160
- return b.updatedAt - a.updatedAt;
3161
- }
2935
+ if (a.successRate !== b.successRate)
2936
+ return b.successRate - a.successRate;
2937
+ if (a.updatedAt !== b.updatedAt)
2938
+ return b.updatedAt - a.updatedAt;
2939
+ const al = ch.lazyPeers.has(a.peerHash) ? 1 : 0;
2940
+ const bl = ch.lazyPeers.has(b.peerHash) ? 1 : 0;
2941
+ if (al !== bl)
2942
+ return bl - al;
3162
2943
  return a.peerHash < b.peerHash ? -1 : a.peerHash > b.peerHash ? 1 : 0;
3163
2944
  });
3164
- let peers = scored.slice(0, ch.neighborRepairPeers).map((s) => s.peerHash);
2945
+ let peers = viable.slice(0, ch.neighborRepairPeers).map((s) => s.peerHash);
3165
2946
  if (peers.length > 0) {
3166
- const bytes = encodeFetchReq(ch.id.key, reqId, slice);
3167
- if (ch.neighborRepairBudgetBps > 0 && ch.neighborRepairTokenCapacity > 0) {
2947
+ const bytes = this.codec.encodeFetchReq(ch.id.key, reqId, slice);
2948
+ if (ch.neighborRepairBudgetBps > 0 &&
2949
+ ch.neighborRepairTokenCapacity > 0) {
3168
2950
  this.refillNeighborRepairTokens(ch, now);
3169
2951
  const costPerPeer = Math.max(1, bytes.byteLength);
3170
2952
  const affordable = Math.floor(ch.neighborRepairTokens / costPerPeer);
@@ -3269,13 +3051,33 @@ export class FanoutTree extends DirectStream {
3269
3051
  const bootstraps = this.getBootstrapsForChannel(ch);
3270
3052
  if (bootstraps.length === 0)
3271
3053
  return;
3272
- const peers = await this.ensureBootstrapPeers(bootstraps, ch.bootstrapDialTimeoutMs, signal, ch.bootstrapMaxPeers);
3054
+ // Announce to ALL trackers, not a random one per round: with B bootstraps
3055
+ // and per-round random selection a given directory only refreshes an
3056
+ // entry every ~announceIntervalMs * B on average, which starves
3057
+ // directories (and exceeds the announce TTL entirely once B grows).
3058
+ // Use already-connected tracker peers and only fall back to dialing when
3059
+ // none are connected: serial dial timeouts here would otherwise make
3060
+ // every announce tick glacial.
3061
+ let peers = ch.cachedAnnounceTrackerPeers ?? [];
3062
+ peers = peers.filter((h) => Boolean(this.peers.get(h)));
3063
+ if (peers.length === 0) {
3064
+ // Dial only when no tracker is connected at all: dialing every tick
3065
+ // makes the announce loop glacial and starves everything behind it.
3066
+ peers = await this.ensureBootstrapPeers(bootstraps, ch.bootstrapDialTimeoutMs, signal, 0);
3067
+ }
3068
+ ch.cachedAnnounceTrackerPeers = peers;
3273
3069
  if (peers.length === 0)
3274
3070
  return;
3275
3071
  this.pruneDisconnectedChildren(ch);
3276
- const freeSlots = Math.max(0, ch.effectiveMaxChildren - ch.children.size);
3072
+ const parentUpgradeNoCapacity = ch.isRoot && ch.parentUpgradeTrackerNoCapacityUntil > now;
3073
+ const freeSlots = parentUpgradeNoCapacity
3074
+ ? 0
3075
+ : Math.max(0, ch.effectiveMaxChildren - ch.children.size);
3076
+ const announceTtlMs = parentUpgradeNoCapacity
3077
+ ? Math.max(1_000, Math.min(ch.announceTtlMs, ch.parentUpgradeTrackerNoCapacityUntil - now))
3078
+ : ch.announceTtlMs;
3277
3079
  const addrs = this.getSelfAnnounceAddrs();
3278
- const bytes = encodeTrackerAnnounce(ch.id.key, ch.announceTtlMs, Number.isFinite(ch.level) ? ch.level : 0xffff, ch.effectiveMaxChildren, freeSlots, ch.bidPerByte, addrs);
3080
+ const bytes = this.codec.encodeTrackerAnnounce(ch.id.key, announceTtlMs, Number.isFinite(ch.level) ? ch.level : 0xffff, ch.effectiveMaxChildren, freeSlots, ch.bidPerByte, addrs);
3279
3081
  await this._sendControlMany(peers, bytes);
3280
3082
  }
3281
3083
  async _announceLoop(ch) {
@@ -3289,6 +3091,18 @@ export class FanoutTree extends DirectStream {
3289
3091
  catch {
3290
3092
  // ignore
3291
3093
  }
3094
+ try {
3095
+ // Child watermark heartbeat first: it only writes to already
3096
+ // connected peers (children/mesh) and must not starve behind the
3097
+ // dial-bound tracker announce below. Parents (root included - it
3098
+ // runs no repair/mesh loop) advertise their have-range to children
3099
+ // so a child whose data writes were silently lost can detect the
3100
+ // gap and repair.
3101
+ await this.maybeSendIHave(ch, Date.now());
3102
+ }
3103
+ catch {
3104
+ // ignore
3105
+ }
3292
3106
  try {
3293
3107
  await this.announceToTrackers(ch, signal);
3294
3108
  }
@@ -3408,27 +3222,62 @@ export class FanoutTree extends DirectStream {
3408
3222
  return { haveFrom, haveToExclusive };
3409
3223
  }
3410
3224
  async maybeSendIHave(ch, now = Date.now()) {
3411
- if (!ch.neighborRepair)
3225
+ const meshEnabled = ch.neighborRepair && ch.neighborMeshPeers > 0;
3226
+ const childWatermarkEnabled = ch.repairEnabled && ch.children.size > 0;
3227
+ if (!meshEnabled && !childWatermarkEnabled) {
3412
3228
  return;
3413
- if (ch.neighborMeshPeers <= 0)
3229
+ }
3230
+ if (ch.neighborAnnounceIntervalMs <= 0) {
3414
3231
  return;
3415
- if (ch.neighborAnnounceIntervalMs <= 0)
3232
+ }
3233
+ if (now - ch.lastIHaveSentAt < ch.neighborAnnounceIntervalMs) {
3416
3234
  return;
3417
- if (ch.maxSeqSeen <= ch.lastIHaveSentMaxSeq)
3235
+ }
3236
+ const hasNewData = ch.maxSeqSeen > ch.lastIHaveSentMaxSeq;
3237
+ // Children also need a periodic watermark even without new data: a burst
3238
+ // of data writes can be lost on a not-yet-writable stream and a child
3239
+ // that received nothing has no gap signal to trigger repair. A periodic
3240
+ // IHAVE from the parent converts that silent loss into a repairable gap.
3241
+ const heartbeatDue = childWatermarkEnabled &&
3242
+ (ch.maxSeqSeen >= 0 || ch.endSeqExclusive > 0) &&
3243
+ now - ch.lastIHaveSentAt >=
3244
+ Math.max(ch.neighborAnnounceIntervalMs * 4, 2_000);
3245
+ if (!hasNewData && !heartbeatDue) {
3418
3246
  return;
3419
- if (now - ch.lastIHaveSentAt < ch.neighborAnnounceIntervalMs)
3247
+ }
3248
+ const recipients = new Set();
3249
+ if (meshEnabled) {
3250
+ for (const h of ch.lazyPeers) {
3251
+ if (this.peers.get(h))
3252
+ recipients.add(h);
3253
+ }
3254
+ }
3255
+ if (childWatermarkEnabled) {
3256
+ for (const h of ch.children.keys()) {
3257
+ if (this.peers.get(h))
3258
+ recipients.add(h);
3259
+ }
3260
+ }
3261
+ if (recipients.size === 0) {
3420
3262
  return;
3421
- const range = this.getHaveRange(ch);
3422
- if (!range)
3423
- return;
3424
- if (range.haveToExclusive <= range.haveFrom)
3425
- return;
3426
- const peers = [...ch.lazyPeers].filter((h) => Boolean(this.peers.get(h)));
3427
- if (peers.length === 0)
3428
- return;
3429
- await this._sendControlMany(peers, encodeIHave(ch.id.key, range.haveFrom, range.haveToExclusive));
3430
- ch.lastIHaveSentAt = now;
3431
- ch.lastIHaveSentMaxSeq = ch.maxSeqSeen;
3263
+ }
3264
+ let sent = false;
3265
+ const range = this.getHaveRange(ch);
3266
+ if (range && range.haveToExclusive > range.haveFrom) {
3267
+ await this._sendControlMany([...recipients], this.codec.encodeIHave(ch.id.key, range.haveFrom, range.haveToExclusive));
3268
+ sent = true;
3269
+ }
3270
+ if (childWatermarkEnabled && ch.endSeqExclusive > 0) {
3271
+ const children = [...ch.children.keys()].filter((h) => this.peers.get(h));
3272
+ if (children.length > 0) {
3273
+ await this._sendControlMany(children, this.codec.encodeEnd(ch.id.key, ch.endSeqExclusive));
3274
+ sent = true;
3275
+ }
3276
+ }
3277
+ if (sent) {
3278
+ ch.lastIHaveSentAt = now;
3279
+ ch.lastIHaveSentMaxSeq = ch.maxSeqSeen;
3280
+ }
3432
3281
  }
3433
3282
  async ensureMeshPeers(ch, signal) {
3434
3283
  if (ch.neighborMeshPeers <= 0)
@@ -3471,6 +3320,10 @@ export class FanoutTree extends DirectStream {
3471
3320
  }
3472
3321
  }
3473
3322
  async refreshMeshCandidates(ch, signal) {
3323
+ if (ch.neighborMeshPeers <= 0)
3324
+ return;
3325
+ if (ch.lazyPeers.size >= ch.neighborMeshPeers)
3326
+ return;
3474
3327
  const bootstraps = this.getBootstrapsForChannel(ch);
3475
3328
  if (bootstraps.length === 0)
3476
3329
  return;
@@ -3497,18 +3350,23 @@ export class FanoutTree extends DirectStream {
3497
3350
  try {
3498
3351
  this.pruneLazyPeers(ch);
3499
3352
  this.pruneHaveByPeer(ch, now);
3353
+ await this.ensureMeshPeers(ch, signal);
3500
3354
  const refreshMs = Math.max(0, ch.neighborMeshRefreshIntervalMs);
3501
- if (refreshMs === 0 || now - lastRefreshAt >= refreshMs) {
3355
+ if (ch.lazyPeers.size < ch.neighborMeshPeers &&
3356
+ (refreshMs === 0 || now - lastRefreshAt >= refreshMs)) {
3502
3357
  lastRefreshAt = now;
3503
3358
  await this.refreshMeshCandidates(ch, signal);
3359
+ await this.ensureMeshPeers(ch, signal);
3504
3360
  }
3505
- await this.ensureMeshPeers(ch, signal);
3506
3361
  await this.maybeSendIHave(ch, now);
3507
3362
  }
3508
3363
  catch {
3509
3364
  // ignore
3510
3365
  }
3511
- const intervals = [ch.neighborAnnounceIntervalMs, ch.neighborMeshRefreshIntervalMs].filter((v) => v > 0);
3366
+ const intervals = [
3367
+ ch.neighborAnnounceIntervalMs,
3368
+ ch.neighborMeshRefreshIntervalMs,
3369
+ ].filter((v) => v > 0);
3512
3370
  const sleepMs = intervals.length > 0 ? Math.min(...intervals) : 500;
3513
3371
  await delay(Math.max(50, sleepMs));
3514
3372
  }
@@ -3522,7 +3380,7 @@ export class FanoutTree extends DirectStream {
3522
3380
  const p = new Promise((resolve) => {
3523
3381
  ch.pendingTrackerQuery.set(reqId, { resolve });
3524
3382
  });
3525
- void this._sendControl(trackerHash, encodeTrackerQuery(ch.id.key, reqId, want));
3383
+ void this._sendControl(trackerHash, this.codec.encodeTrackerQuery(ch.id.key, reqId, want));
3526
3384
  const res = await Promise.race([
3527
3385
  p,
3528
3386
  delay(perTrackerTimeout, { signal }).then(() => null),
@@ -3552,7 +3410,11 @@ export class FanoutTree extends DirectStream {
3552
3410
  return false;
3553
3411
  try {
3554
3412
  await this.components.connectionManager.openConnection(a);
3555
- await this.waitFor(hash, { seek: "present", timeout: timeoutMs, signal });
3413
+ await this.waitFor(hash, {
3414
+ seek: "present",
3415
+ timeout: timeoutMs,
3416
+ signal,
3417
+ });
3556
3418
  return true;
3557
3419
  }
3558
3420
  catch {
@@ -3567,7 +3429,9 @@ export class FanoutTree extends DirectStream {
3567
3429
  return [];
3568
3430
  try {
3569
3431
  const peer = await this.components.peerStore.get(stream.peerId);
3570
- const addresses = Array.isArray(peer?.addresses) ? peer.addresses : [];
3432
+ const addresses = Array.isArray(peer?.addresses)
3433
+ ? peer.addresses
3434
+ : [];
3571
3435
  const out = [];
3572
3436
  for (const a of addresses) {
3573
3437
  const ma = a?.multiaddr ?? a;
@@ -3634,7 +3498,10 @@ export class FanoutTree extends DirectStream {
3634
3498
  if (out.length >= max)
3635
3499
  break;
3636
3500
  seen.add(e.hash);
3637
- out.push({ hash: e.hash, addrs: e.addrs.slice(0, JOIN_REJECT_REDIRECT_ADDR_MAX) });
3501
+ out.push({
3502
+ hash: e.hash,
3503
+ addrs: e.addrs.slice(0, JOIN_REJECT_REDIRECT_ADDR_MAX),
3504
+ });
3638
3505
  }
3639
3506
  }
3640
3507
  if (out.length >= max)
@@ -3665,12 +3532,166 @@ export class FanoutTree extends DirectStream {
3665
3532
  }
3666
3533
  async sendJoinReject(ch, toHash, reqId, reason) {
3667
3534
  const redirects = await this.pickJoinRejectRedirects(ch, toHash, JOIN_REJECT_REDIRECT_MAX);
3668
- await this._sendControl(toHash, encodeJoinReject(ch.id.key, reqId, reason, redirects));
3535
+ await this._sendControl(toHash, this.codec.encodeJoinReject(ch.id.key, reqId, reason, redirects));
3669
3536
  }
3670
3537
  async sendTrackerFeedback(ch, trackerPeers, candidateHash, event, reason = 0) {
3671
3538
  if (trackerPeers.length === 0)
3672
3539
  return;
3673
- await this._sendControlMany(trackerPeers, encodeTrackerFeedback(ch.id.key, candidateHash, event, reason));
3540
+ await this._sendControlMany(trackerPeers, this.codec.encodeTrackerFeedback(ch.id.key, candidateHash, event, reason));
3541
+ }
3542
+ pruneParentUpgradeReservations(ch, now = Date.now()) {
3543
+ for (const [hash, reservation] of ch.parentUpgradeReservationsByHash) {
3544
+ if (reservation.expiresAt <= now) {
3545
+ ch.parentUpgradeReservationsByHash.delete(hash);
3546
+ ch.metrics.parentUpgradeRootReservationExpired += 1;
3547
+ continue;
3548
+ }
3549
+ if (ch.children.has(hash)) {
3550
+ ch.parentUpgradeReservationsByHash.delete(hash);
3551
+ }
3552
+ }
3553
+ }
3554
+ parentUpgradeReservationCount(ch, excludeHash, now = Date.now()) {
3555
+ this.pruneParentUpgradeReservations(ch, now);
3556
+ let count = 0;
3557
+ for (const hash of ch.parentUpgradeReservationsByHash.keys()) {
3558
+ if (hash === excludeHash)
3559
+ continue;
3560
+ if (ch.children.has(hash))
3561
+ continue;
3562
+ count += 1;
3563
+ }
3564
+ return count;
3565
+ }
3566
+ createParentUpgradeReservation(ch, toHash, minFreeSlots = 0, now = Date.now()) {
3567
+ if (!ch.isRoot || ch.children.has(toHash))
3568
+ return 0;
3569
+ const maxChildren = Math.max(0, Math.floor(ch.effectiveMaxChildren));
3570
+ if (maxChildren <= 0)
3571
+ return 0;
3572
+ const reserved = this.parentUpgradeReservationCount(ch, toHash, now);
3573
+ const requestedMinFreeSlots = Math.max(0, Math.floor(minFreeSlots));
3574
+ const freeSlots = maxChildren - ch.children.size - reserved;
3575
+ if (freeSlots <= 0 ||
3576
+ (requestedMinFreeSlots > 0 && freeSlots < requestedMinFreeSlots)) {
3577
+ ch.metrics.parentUpgradeRootReservationBlocked += 1;
3578
+ return 0;
3579
+ }
3580
+ const token = (this.random() * 0xffffffff) >>> 0 || 1;
3581
+ ch.parentUpgradeReservationsByHash.set(toHash, {
3582
+ token,
3583
+ expiresAt: now + PARENT_UPGRADE_ROOT_RESERVATION_TTL_MS,
3584
+ minFreeSlots: requestedMinFreeSlots,
3585
+ });
3586
+ ch.metrics.parentUpgradeRootReservationCreated += 1;
3587
+ return token;
3588
+ }
3589
+ consumeParentUpgradeReservation(ch, fromHash, token, now = Date.now()) {
3590
+ if (!ch.isRoot || token <= 0)
3591
+ return false;
3592
+ this.pruneParentUpgradeReservations(ch, now);
3593
+ const reservation = ch.parentUpgradeReservationsByHash.get(fromHash);
3594
+ if (!reservation)
3595
+ return false;
3596
+ if (reservation.expiresAt <= now) {
3597
+ ch.parentUpgradeReservationsByHash.delete(fromHash);
3598
+ ch.metrics.parentUpgradeRootReservationExpired += 1;
3599
+ return false;
3600
+ }
3601
+ if (reservation.token !== token)
3602
+ return false;
3603
+ const requestedMinFreeSlots = Math.max(0, Math.floor(reservation.minFreeSlots ?? 0));
3604
+ if (requestedMinFreeSlots > 0) {
3605
+ const maxChildren = Math.max(0, Math.floor(ch.effectiveMaxChildren));
3606
+ const freeSlots = Math.max(0, maxChildren - ch.children.size);
3607
+ if (freeSlots < requestedMinFreeSlots) {
3608
+ ch.parentUpgradeReservationsByHash.delete(fromHash);
3609
+ ch.metrics.parentUpgradeRootReservationMarginRejected += 1;
3610
+ return false;
3611
+ }
3612
+ }
3613
+ ch.parentUpgradeReservationsByHash.delete(fromHash);
3614
+ ch.metrics.parentUpgradeRootReservationConsumed += 1;
3615
+ return true;
3616
+ }
3617
+ isRootedForParentProbe(ch) {
3618
+ if (ch.isRoot)
3619
+ return true;
3620
+ const route = ch.routeFromRoot;
3621
+ return Boolean(ch.parent &&
3622
+ Array.isArray(route) &&
3623
+ route.length >= 2 &&
3624
+ route[0] === ch.id.root &&
3625
+ route[route.length - 1] === this.publicKeyHash);
3626
+ }
3627
+ encodeParentProbeReplyForChannel(ch, reqId, toHash, minFreeSlots = 0, reserveRootCapacity = true) {
3628
+ this.pruneDisconnectedChildren(ch);
3629
+ const rooted = this.isRootedForParentProbe(ch);
3630
+ const requestedMinFreeSlots = Math.max(0, Math.floor(minFreeSlots));
3631
+ const maxChildren = Math.max(0, Math.floor(ch.effectiveMaxChildren));
3632
+ const reserved = ch.isRoot
3633
+ ? this.parentUpgradeReservationCount(ch, toHash)
3634
+ : 0;
3635
+ const freeSlots = rooted
3636
+ ? Math.max(0, maxChildren - ch.children.size - reserved)
3637
+ : 0;
3638
+ const requesterHasEnoughSlots = freeSlots > 0 &&
3639
+ (requestedMinFreeSlots <= 0 || freeSlots >= requestedMinFreeSlots);
3640
+ if (ch.isRoot &&
3641
+ rooted &&
3642
+ maxChildren > 0 &&
3643
+ !ch.children.has(toHash) &&
3644
+ !requesterHasEnoughSlots) {
3645
+ ch.metrics.parentUpgradeRootReservationBlocked += 1;
3646
+ }
3647
+ const reservationToken = ch.isRoot && rooted && requesterHasEnoughSlots && reserveRootCapacity
3648
+ ? this.createParentUpgradeReservation(ch, toHash, requestedMinFreeSlots)
3649
+ : 0;
3650
+ const repairing = ch.missingSeqs.size > 0;
3651
+ const overloaded = ch.overloadStreak > 0;
3652
+ let flags = 0;
3653
+ if (rooted)
3654
+ flags |= PARENT_PROBE_FLAG_ROOTED;
3655
+ if (rooted &&
3656
+ (reservationToken > 0 ||
3657
+ (!ch.isRoot && requesterHasEnoughSlots) ||
3658
+ (ch.isRoot && requesterHasEnoughSlots && !reserveRootCapacity))) {
3659
+ flags |= PARENT_PROBE_FLAG_ACCEPTING;
3660
+ }
3661
+ if (repairing)
3662
+ flags |= PARENT_PROBE_FLAG_REPAIRING;
3663
+ if (overloaded)
3664
+ flags |= PARENT_PROBE_FLAG_OVERLOADED;
3665
+ return this.codec.encodeParentProbeReply(ch.id.key, reqId, {
3666
+ flags,
3667
+ level: Number.isFinite(ch.level) ? ch.level : 0xffff,
3668
+ maxChildren,
3669
+ freeSlots,
3670
+ // For roots, expose reserved slots as child pressure so concurrent
3671
+ // upgrade probes cannot all pass the load-ratio check from stale state.
3672
+ children: ch.isRoot ? ch.children.size + reserved : ch.children.size,
3673
+ haveToExclusive: ch.maxSeqSeen >= 0 ? ch.maxSeqSeen + 1 : 0,
3674
+ missingSeqs: ch.missingSeqs.size,
3675
+ dataWriteDrops: ch.metrics.dataWriteDrops,
3676
+ droppedForwards: ch.droppedForwards,
3677
+ reservationToken,
3678
+ });
3679
+ }
3680
+ async probeParentCandidate(ch, parentHash, timeoutMs, signal, minFreeSlots = 0, reserveRootCapacity = true) {
3681
+ if (!this.peers.get(parentHash))
3682
+ return;
3683
+ const reqId = (this.random() * 0xffffffff) >>> 0;
3684
+ const p = new Promise((resolve) => {
3685
+ ch.pendingParentProbe.set(reqId, { resolve });
3686
+ });
3687
+ await this._sendControl(parentHash, this.codec.encodeParentProbeReq(ch.id.key, reqId, minFreeSlots, reserveRootCapacity));
3688
+ const res = await Promise.race([
3689
+ p,
3690
+ delay(Math.max(1, timeoutMs), { signal }).then(() => undefined),
3691
+ ]);
3692
+ if (!res)
3693
+ ch.pendingParentProbe.delete(reqId);
3694
+ return res;
3674
3695
  }
3675
3696
  async _joinLoop(ch, joinOpts) {
3676
3697
  const retryMs = Math.max(1, Math.floor(joinOpts.retryMs ?? 200));
@@ -3686,6 +3707,9 @@ export class FanoutTree extends DirectStream {
3686
3707
  const trackerQueryIntervalMs = Math.max(0, Math.floor(joinOpts.trackerQueryIntervalMs ?? ch.trackerQueryIntervalMs));
3687
3708
  const joinAttemptsPerRound = Math.max(1, Math.floor(joinOpts.joinAttemptsPerRound ?? 8));
3688
3709
  const candidateCooldownMs = Math.max(0, Math.floor(joinOpts.candidateCooldownMs ?? 2_000));
3710
+ const parentUpgrade = this.nativeFanout
3711
+ ? this.nativeFanout.normalizeParentUpgradePolicy(joinOpts)
3712
+ : normalizeParentUpgradePolicy(joinOpts);
3689
3713
  const candidateScoringModeRaw = joinOpts.candidateScoringMode ?? "ranked-shuffle";
3690
3714
  const candidateScoringMode = candidateScoringModeRaw === "ranked-strict" ||
3691
3715
  candidateScoringModeRaw === "weighted" ||
@@ -3705,6 +3729,56 @@ export class FanoutTree extends DirectStream {
3705
3729
  ? anySignal([this.closeController.signal, joinOpts.signal])
3706
3730
  : this.closeController.signal;
3707
3731
  const signal = combinedSignal;
3732
+ let nextParentUpgradeCheckAt = 0;
3733
+ let parentUpgradeCheckSeq = 0;
3734
+ let parentUpgradeActiveGuardBackoffMs = 0;
3735
+ const scheduleNextParentUpgradeCheck = (now, first = false, minDelayMs = parentUpgrade.intervalMs) => {
3736
+ if (parentUpgrade.intervalMs <= 0) {
3737
+ nextParentUpgradeCheckAt = 0;
3738
+ return;
3739
+ }
3740
+ const factor = first
3741
+ ? stableUnitInterval(`${ch.id.suffixKey}:${this.publicKeyHash}:parent-upgrade-phase`)
3742
+ : 0.75 +
3743
+ stableUnitInterval(`${ch.id.suffixKey}:${this.publicKeyHash}:parent-upgrade-interval:${parentUpgradeCheckSeq++}`) *
3744
+ 0.5;
3745
+ nextParentUpgradeCheckAt =
3746
+ now +
3747
+ Math.max(1, Math.floor(parentUpgrade.intervalMs * factor), Math.floor(minDelayMs));
3748
+ };
3749
+ const resetParentUpgradeActiveGuardBackoff = () => {
3750
+ parentUpgradeActiveGuardBackoffMs = 0;
3751
+ };
3752
+ const parentUpgradeGuardDelayMs = (reason, now) => {
3753
+ const baseDelayMs = (() => {
3754
+ switch (reason) {
3755
+ case "data":
3756
+ return Math.max(parentUpgrade.quietMs, parentUpgrade.repairQuietMs);
3757
+ case "repair":
3758
+ return parentUpgrade.repairQuietMs;
3759
+ case "quiet":
3760
+ return ch.lastParentUpgradeActivityAt > 0
3761
+ ? Math.max(0, parentUpgrade.quietMs -
3762
+ (now - ch.lastParentUpgradeActivityAt))
3763
+ : parentUpgrade.quietMs;
3764
+ case "cooldown":
3765
+ return Math.max(0, ch.parentUpgradeBackoffUntil - now, ch.parentUpgradeLastAt > 0
3766
+ ? parentUpgrade.cooldownMs - (now - ch.parentUpgradeLastAt)
3767
+ : 0);
3768
+ default:
3769
+ return parentUpgrade.intervalMs;
3770
+ }
3771
+ })();
3772
+ if (reason !== "data" && reason !== "repair") {
3773
+ resetParentUpgradeActiveGuardBackoff();
3774
+ return baseDelayMs;
3775
+ }
3776
+ parentUpgradeActiveGuardBackoffMs =
3777
+ parentUpgradeActiveGuardBackoffMs > 0
3778
+ ? Math.min(Math.max(parentUpgrade.failedBackoff.maxMs, baseDelayMs), Math.max(baseDelayMs, parentUpgradeActiveGuardBackoffMs * 2))
3779
+ : baseDelayMs;
3780
+ return parentUpgradeActiveGuardBackoffMs;
3781
+ };
3708
3782
  try {
3709
3783
  for (;;) {
3710
3784
  if (ch.closed) {
@@ -3730,6 +3804,8 @@ export class FanoutTree extends DirectStream {
3730
3804
  ch.metrics.reparentDisconnect += 1;
3731
3805
  const hadChildren = ch.children.size > 0;
3732
3806
  this.detachFromParent(ch);
3807
+ nextParentUpgradeCheckAt = 0;
3808
+ resetParentUpgradeActiveGuardBackoff();
3733
3809
  // If we lose our parent, we are no longer on the rooted tree; detach children so
3734
3810
  // they can rejoin as well (prevents stable disconnected components).
3735
3811
  void this.kickChildren(ch).catch(() => { });
@@ -3742,50 +3818,130 @@ export class FanoutTree extends DirectStream {
3742
3818
  const endedAndComplete = ch.endSeqExclusive > 0 &&
3743
3819
  ch.missingSeqs.size === 0 &&
3744
3820
  ch.nextExpectedSeq >= ch.endSeqExclusive;
3745
- const expectingData = ch.missingSeqs.size > 0 || (ch.maxDataAgeMs > 0 && !endedAndComplete);
3746
- if (staleAfterMs > 0 &&
3747
- ch.receivedAnyParentData &&
3748
- ch.lastParentDataAt > 0 &&
3749
- Date.now() - ch.lastParentDataAt > staleAfterMs &&
3750
- expectingData) {
3821
+ const expectingData = ch.missingSeqs.size > 0 ||
3822
+ (ch.repairEnabled && ch.maxSeqSeen >= 0 && ch.endSeqExclusive < 0) ||
3823
+ (ch.maxDataAgeMs > 0 && !endedAndComplete);
3824
+ // Repair requests travel child->parent on a direction that works
3825
+ // even when parent->child data writes silently fail. A repair that
3826
+ // does not fill a gap is not sufficient evidence by itself though:
3827
+ // the repair payload can be dropped or the parent cache can miss.
3828
+ // Re-parent only after both repeated unanswered repairs and a
3829
+ // quiet parent liveness window.
3830
+ const parentRepairDeadLivenessMs = Math.max(PARENT_REPAIR_DEAD_MIN_LIVENESS_MS, ch.repairIntervalMs * PARENT_REPAIR_DEAD_STREAK_THRESHOLD, ch.neighborAnnounceIntervalMs * 8);
3831
+ const parentRepairNow = Date.now();
3832
+ const parentLivenessQuietMs = ch.lastParentDataAt > 0 ? parentRepairNow - ch.lastParentDataAt : 0;
3833
+ const parentRepairDead = ch.repairEnabled &&
3834
+ ch.parentRepairUnansweredStreak >=
3835
+ PARENT_REPAIR_DEAD_STREAK_THRESHOLD &&
3836
+ parentLivenessQuietMs >= parentRepairDeadLivenessMs;
3837
+ if (parentRepairDead ||
3838
+ (staleAfterMs > 0 &&
3839
+ ch.receivedAnyParentData &&
3840
+ ch.lastParentDataAt > 0 &&
3841
+ Date.now() - ch.lastParentDataAt > staleAfterMs &&
3842
+ expectingData)) {
3751
3843
  // Parent is "alive" at the stream layer but we're not receiving
3752
3844
  // data at the expected rate; detach and try to re-parent.
3753
3845
  ch.metrics.reparentStale += 1;
3754
3846
  ch.parent = undefined;
3847
+ ch.parentRepairUnansweredStreak = 0;
3755
3848
  ch.level = Number.POSITIVE_INFINITY;
3756
3849
  ch.routeFromRoot = undefined;
3757
3850
  ch.routeByPeer.clear();
3758
3851
  ch.lastParentDataAt = 0;
3852
+ ch.lastParentUpgradeActivityAt = 0;
3759
3853
  ch.receivedAnyParentData = false;
3760
3854
  ch.pendingJoin.clear();
3761
- ch.pendingRouteQuery.clear();
3762
- for (const pending of ch.pendingRouteProxy.values()) {
3763
- clearTimeout(pending.timer);
3855
+ ch.pendingParentProbe.clear();
3856
+ ch.parentShadow = undefined;
3857
+ void Promise.all(this.clearParentUpgradeGrace(ch, true, true)).catch(() => { });
3858
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
3859
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
3764
3860
  }
3765
- ch.pendingRouteProxy.clear();
3861
+ ch.parentUpgradeRetryAfterSeq = -1;
3862
+ ch.parentProbeRejectUntilByHash.clear();
3863
+ ch.parentProbeRejectBackoffMsByHash.clear();
3864
+ nextParentUpgradeCheckAt = 0;
3865
+ resetParentUpgradeActiveGuardBackoff();
3866
+ ch.pendingRouteQuery.clear();
3867
+ this.clearRouteProxies(ch);
3766
3868
  void this.kickChildren(ch).catch(() => { });
3767
- await delay(retryMs);
3869
+ await delay(retryMs, { signal });
3768
3870
  continue;
3769
3871
  }
3872
+ if (parentUpgrade.intervalMs > 0 && ch.level > 1) {
3873
+ const now = Date.now();
3874
+ if (nextParentUpgradeCheckAt === 0) {
3875
+ scheduleNextParentUpgradeCheck(now, true, parentUpgrade.dataGuard || parentUpgrade.repairGuard
3876
+ ? Math.max(parentUpgrade.quietMs, parentUpgrade.repairQuietMs)
3877
+ : parentUpgrade.intervalMs);
3878
+ }
3879
+ const due = now >= nextParentUpgradeCheckAt;
3880
+ if (due) {
3881
+ const gateOptions = {
3882
+ leafOnly: parentUpgrade.leafOnly,
3883
+ repairGuard: parentUpgrade.repairGuard,
3884
+ dataGuard: parentUpgrade.dataGuard,
3885
+ endedAndComplete,
3886
+ maxPerPeer: parentUpgrade.maxPerPeer,
3887
+ cooldownMs: parentUpgrade.cooldownMs,
3888
+ quietMs: parentUpgrade.quietMs,
3889
+ repairQuietMs: parentUpgrade.repairQuietMs,
3890
+ now,
3891
+ };
3892
+ const gate = this.nativeFanout
3893
+ ? this.nativeFanout.evaluateParentUpgradeGate(ch, gateOptions)
3894
+ : evaluateParentUpgradeGate(ch, gateOptions);
3895
+ if ("reason" in gate) {
3896
+ recordParentUpgradeSkip(ch.metrics, gate.reason);
3897
+ scheduleNextParentUpgradeCheck(now, false, parentUpgradeGuardDelayMs(gate.reason, now));
3898
+ }
3899
+ else {
3900
+ resetParentUpgradeActiveGuardBackoff();
3901
+ const improved = await this.maybeImproveParent(ch, {
3902
+ signal,
3903
+ candidateShuffleTopK,
3904
+ candidateScoringMode,
3905
+ candidateScoringWeights,
3906
+ joinAttemptsPerRound,
3907
+ joinReqTimeoutMs,
3908
+ parentUpgrade,
3909
+ trackerPeers: ch.cachedBootstrapPeers.filter((h) => Boolean(this.peers.get(h))),
3910
+ });
3911
+ scheduleNextParentUpgradeCheck(Date.now());
3912
+ if (improved) {
3913
+ ch.metrics.reparentUpgrade += 1;
3914
+ ch.parentUpgradeCount += 1;
3915
+ ch.parentUpgradeLastAt = Date.now();
3916
+ ch.parentUpgradeBackoffMs = 0;
3917
+ ch.parentUpgradeBackoffUntil = 0;
3918
+ ch.parentUpgradeRetryAfterSeq = -1;
3919
+ ch.parentUpgradeStaleRootProbeRound = 0;
3920
+ }
3921
+ }
3922
+ }
3923
+ }
3770
3924
  if (!ch.joinedOnce)
3771
3925
  ch.joinedOnce = createDeferred();
3772
3926
  ch.joinedOnce.resolve();
3773
3927
  ch.joinedAtLeastOnce = true;
3774
3928
  // Once attached, we don't need a fast retry cadence; keep polling coarse to
3775
3929
  // avoid excessive timers when simulating many nodes in one process.
3776
- await delay(Math.max(retryMs, 1_000));
3930
+ await delay(Math.max(retryMs, 1_000), { signal });
3777
3931
  continue;
3778
3932
  }
3779
3933
  // `timeoutMs` is meant to bound the initial `joinChannel()` await, not to
3780
3934
  // stop re-parenting attempts for long-running nodes.
3781
- if (!ch.joinedAtLeastOnce && timeoutMs > 0 && Date.now() - start > timeoutMs) {
3935
+ if (!ch.joinedAtLeastOnce &&
3936
+ timeoutMs > 0 &&
3937
+ Date.now() - start > timeoutMs) {
3782
3938
  const bootstrapsCount = this.getBootstrapsForChannel(ch).length;
3783
3939
  const rootPeer = this.peers.get(ch.id.root);
3784
3940
  const rootNeighbor = Boolean(rootPeer && rootPeer.isReadable && rootPeer.isWritable);
3785
3941
  const bootstrapHint = bootstrapsCount === 0 && !rootNeighbor
3786
3942
  ? " No fanout bootstraps are configured for this channel, and the root is not a direct neighbor. If this peer reached the network via a bootstrap or relay node, initialize it with Peerbit.bootstrap(...) instead of Peerbit.dial(...), or configure FanoutTree.setBootstraps(...) before joining sharded topics."
3787
3943
  : "";
3788
- throw new Error(`fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount}).${bootstrapHint}`);
3944
+ throw new Error(`fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount} joinReqSent=${ch.metrics.joinReqSent} joinAcceptReceived=${ch.metrics.joinAcceptReceived} joinRejectReceived=${ch.metrics.joinRejectReceived} peerResets=${ch.metrics.joinPeerResets}).${bootstrapHint}`);
3789
3945
  }
3790
3946
  const cooldownMs = ch.rejoinCooldownUntil - Date.now();
3791
3947
  if (cooldownMs > 0) {
@@ -3881,12 +4037,19 @@ export class FanoutTree extends DirectStream {
3881
4037
  source: 1,
3882
4038
  });
3883
4039
  }
3884
- // Fallback: try a bounded set of already-connected peers.
4040
+ // Fallback: try a bounded set of already-connected peers. Bootstrap
4041
+ // peers are often just trackers, but they can also be the only
4042
+ // connected channel host and may not list themselves in tracker
4043
+ // replies. Keep them as low-priority candidates unless tracker state
4044
+ // already represented them above.
4045
+ const bootstrapPeerSet = new Set(bootstrapPeers);
3885
4046
  let connectedFallbackAdded = 0;
3886
4047
  const connectedFallbackMax = 64;
3887
4048
  for (const h of this.peers.keys()) {
3888
4049
  if (h === this.publicKeyHash)
3889
4050
  continue;
4051
+ if (bootstrapPeerSet.has(h) && candidatesByHash.has(h))
4052
+ continue;
3890
4053
  upsertCandidate({
3891
4054
  hash: h,
3892
4055
  addrs: [],
@@ -3926,12 +4089,14 @@ export class FanoutTree extends DirectStream {
3926
4089
  if (until != null && until > now)
3927
4090
  nextAt = Math.min(nextAt, until);
3928
4091
  }
3929
- const waitMs = nextAt !== Number.POSITIVE_INFINITY ? Math.max(1, nextAt - now) : retryMs;
4092
+ const waitMs = nextAt !== Number.POSITIVE_INFINITY
4093
+ ? Math.max(1, nextAt - now)
4094
+ : retryMs;
3930
4095
  const capMs = Math.max(retryMs, trackerQueryIntervalMs > 0 ? trackerQueryIntervalMs : retryMs);
3931
- await delay(Math.max(1, Math.min(waitMs, capMs)));
4096
+ await delay(Math.max(1, Math.min(waitMs, capMs)), { signal });
3932
4097
  continue;
3933
4098
  }
3934
- await delay(retryMs);
4099
+ await delay(retryMs, { signal });
3935
4100
  continue;
3936
4101
  }
3937
4102
  let ordered = [...candidates];
@@ -4097,6 +4262,7 @@ export class FanoutTree extends DirectStream {
4097
4262
  }
4098
4263
  }
4099
4264
  if (res.timedOut) {
4265
+ this.noteJoinTimeout(ch, c.hash);
4100
4266
  try {
4101
4267
  await this.sendTrackerFeedback(ch, bootstrapPeers, c.hash, TRACKER_FEEDBACK_JOIN_TIMEOUT);
4102
4268
  }
@@ -4137,26 +4303,922 @@ export class FanoutTree extends DirectStream {
4137
4303
  const stream = this.peers.get(h);
4138
4304
  if (!stream)
4139
4305
  continue;
4140
- void this.components.connectionManager.closeConnections(stream.peerId).catch(() => { });
4306
+ void this.components.connectionManager
4307
+ .closeConnections(stream.peerId)
4308
+ .catch(() => { });
4141
4309
  }
4142
4310
  continue;
4143
4311
  }
4144
- await delay(retryMs);
4312
+ await delay(retryMs, { signal });
4145
4313
  }
4146
4314
  }
4147
4315
  finally {
4148
4316
  signal.clear?.();
4149
4317
  }
4150
4318
  }
4151
- async tryJoinOnce(ch, parentHash, reqId, timeoutMs, signal) {
4152
- if (ch.parent)
4319
+ async maybeImproveParent(ch, options) {
4320
+ const parentUpgrade = options.parentUpgrade;
4321
+ const currentParent = ch.parent;
4322
+ if (!currentParent || ch.closed || ch.isRoot || ch.level <= 1)
4323
+ return false;
4324
+ if (parentUpgrade.mode === "shadow" &&
4325
+ this.parentUpgradeShadowInFlightSuffixKey != null &&
4326
+ this.parentUpgradeShadowInFlightSuffixKey !== ch.id.suffixKey) {
4327
+ ch.metrics.reparentUpgradeSkipBudget += 1;
4328
+ return false;
4329
+ }
4330
+ const staleRootProbeRound = Math.max(0, Math.floor(ch.parentUpgradeStaleRootProbeRound || 0));
4331
+ ch.parentUpgradeStaleRootProbeRound = Math.min(0x7fffffff, staleRootProbeRound + 1);
4332
+ const upgradeRandom = (scope) => stableUnitInterval(`${ch.id.suffixKey}:${this.publicKeyHash}:${currentParent}:${scope}`);
4333
+ const clearFailedBackoff = () => {
4334
+ ch.parentUpgradeBackoffMs = 0;
4335
+ ch.parentUpgradeBackoffUntil = 0;
4336
+ };
4337
+ const clearShadowInFlight = () => {
4338
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
4339
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
4340
+ }
4341
+ };
4342
+ const resetShadow = () => {
4343
+ if (!ch.parentShadow)
4344
+ return;
4345
+ ch.parentShadow = undefined;
4346
+ clearShadowInFlight();
4347
+ ch.metrics.parentShadowReset += 1;
4348
+ };
4349
+ const deferParentUpgradeRetryUntilFreshData = (multiplier = 1) => {
4350
+ const minFreshMessages = Math.max(1, parentUpgrade.shadow.dualPathMinMessages *
4351
+ Math.max(1, Math.floor(multiplier)));
4352
+ ch.parentUpgradeRetryAfterSeq = Math.max(ch.parentUpgradeRetryAfterSeq, ch.maxSeqSeen + minFreshMessages - 1);
4353
+ };
4354
+ const applyFailedBackoff = () => {
4355
+ const minMs = parentUpgrade.failedBackoff.minMs;
4356
+ const maxMs = parentUpgrade.failedBackoff.maxMs;
4357
+ if (minMs <= 0 || maxMs <= 0) {
4358
+ ch.parentUpgradeBackoffMs = 0;
4359
+ ch.parentUpgradeBackoffUntil = 0;
4360
+ return;
4361
+ }
4362
+ const nextMs = ch.parentUpgradeBackoffMs > 0
4363
+ ? Math.min(maxMs, Math.max(minMs, ch.parentUpgradeBackoffMs * 2))
4364
+ : minMs;
4365
+ ch.parentUpgradeBackoffMs = nextMs;
4366
+ const jitter = 0.75 + upgradeRandom(`failed-backoff:${nextMs}`) * 0.5;
4367
+ ch.parentUpgradeBackoffUntil =
4368
+ Date.now() + Math.max(1, Math.floor(nextMs * jitter));
4369
+ };
4370
+ const isDirectNeighbor = (hash) => {
4371
+ const peer = this.peers.get(hash);
4372
+ if (!peer)
4373
+ return false;
4374
+ try {
4375
+ const conns = this.components.connectionManager.getConnections(peer.peerId);
4376
+ return (conns?.length ?? 0) > 0;
4377
+ }
4378
+ catch {
4379
+ return peer.isReadable || peer.isWritable;
4380
+ }
4381
+ };
4382
+ const candidatesByHash = new Map();
4383
+ const upsertCandidate = (c) => {
4384
+ const prev = candidatesByHash.get(c.hash);
4385
+ if (!prev) {
4386
+ candidatesByHash.set(c.hash, { ...c });
4387
+ return;
4388
+ }
4389
+ if (prev.addrs.length === 0 && c.addrs.length > 0)
4390
+ prev.addrs = c.addrs;
4391
+ prev.level = Math.min(prev.level, c.level);
4392
+ prev.freeSlots = Math.max(prev.freeSlots, c.freeSlots);
4393
+ prev.bidPerByte = Math.max(prev.bidPerByte, c.bidPerByte);
4394
+ prev.source = Math.min(prev.source, c.source);
4395
+ };
4396
+ const nonRootMinLevelGain = Math.max(parentUpgrade.minLevelGain, parentUpgrade.nonRootMinLevelGain);
4397
+ const rootMinLevelGain = Math.max(parentUpgrade.minLevelGain, parentUpgrade.rootMinLevelGain);
4398
+ const rootMinSubtreeGain = Math.max(parentUpgrade.minLevelGain, parentUpgrade.rootMinSubtreeGain);
4399
+ const levelGainFor = (parentLevel) => ch.level - (parentLevel + 1);
4400
+ const impactedPeersForRoot = () => Math.max(1, 1 + ch.children.size);
4401
+ const rootSubtreeGainFor = (parentLevel) => levelGainFor(parentLevel) * impactedPeersForRoot();
4402
+ const isEnoughLevelGain = (hash, parentLevel) => {
4403
+ const levelGain = levelGainFor(parentLevel);
4404
+ if (hash !== ch.id.root)
4405
+ return levelGain >= nonRootMinLevelGain;
4406
+ if (levelGain >= rootMinLevelGain)
4407
+ return true;
4408
+ return rootSubtreeGainFor(parentLevel) >= rootMinSubtreeGain;
4409
+ };
4410
+ let localChannelCount = 1;
4411
+ if (this.channelsBySuffixKey instanceof Map) {
4412
+ localChannelCount = 0;
4413
+ for (const localCh of this.channelsBySuffixKey.values()) {
4414
+ if (localCh.closed)
4415
+ continue;
4416
+ localChannelCount += 1;
4417
+ if (localChannelCount > 1)
4418
+ break;
4419
+ }
4420
+ }
4421
+ const singleChannelBranch = ch.children.size > 0 && localChannelCount <= 1;
4422
+ const multiChannelBranch = ch.children.size > 0 && localChannelCount > 1;
4423
+ const endedAndComplete = ch.endSeqExclusive > 0 &&
4424
+ ch.missingSeqs.size === 0 &&
4425
+ ch.nextExpectedSeq >= ch.endSeqExclusive;
4426
+ const singleChannelSettledLeaf = ch.children.size === 0 && localChannelCount <= 1 && endedAndComplete;
4427
+ const multiChannelSettledLeaf = ch.children.size === 0 && localChannelCount > 1 && endedAndComplete;
4428
+ const knownChannelPeerCount = Math.max(1, ch.channelPeers?.size ?? 1);
4429
+ const defaultStaleRootProbeProbability = 0.015625;
4430
+ const configuredStaleRootProbeProbabilityRaw = Number(parentUpgrade.staleRootProbeProbability);
4431
+ const staleRootProbeBaseProbability = Number.isFinite(configuredStaleRootProbeProbabilityRaw)
4432
+ ? Math.max(0, Math.min(1, configuredStaleRootProbeProbabilityRaw))
4433
+ : defaultStaleRootProbeProbability;
4434
+ const defaultMultiChannelSettledLeafRootCandidate = parentUpgrade.leafOnly &&
4435
+ multiChannelSettledLeaf &&
4436
+ staleRootProbeBaseProbability <= defaultStaleRootProbeProbability;
4437
+ const hasDefaultMultiChannelLeafRootSignal = () => {
4438
+ if (!defaultMultiChannelSettledLeafRootCandidate)
4439
+ return true;
4440
+ if (!isEnoughLevelGain(ch.id.root, 0))
4441
+ return false;
4442
+ if ((ch.parentDataLatencySamples ?? 0) < 8)
4443
+ return false;
4444
+ if ((ch.parentDataLatencyEwmaMs ?? 0) < 64 &&
4445
+ (ch.parentDataLatencyMaxMs ?? 0) < 256) {
4446
+ return false;
4447
+ }
4448
+ if (staleRootProbeBaseProbability <= 0)
4449
+ return false;
4450
+ const sampleProbability = Math.min(1, staleRootProbeBaseProbability);
4451
+ return (stableUnitInterval(`${ch.id.suffixKey}:${this.publicKeyHash}:${ch.id.root}:multi-channel-leaf-root-signal:${ch.maxSeqSeen}`) < sampleProbability);
4452
+ };
4453
+ const defaultMultiChannelLeafRootSignal = hasDefaultMultiChannelLeafRootSignal();
4454
+ if (defaultMultiChannelSettledLeafRootCandidate &&
4455
+ defaultMultiChannelLeafRootSignal &&
4456
+ ch.id.root !== this.publicKeyHash &&
4457
+ isDirectNeighbor(ch.id.root) &&
4458
+ (options.trackerPeers?.length ?? 0) > 0) {
4459
+ const rootMinFreeSlots = parentUpgrade.rootMinFreeSlots;
4460
+ const cachedRoot = ch.cachedTrackerCandidates.find((c) => c.hash === ch.id.root);
4461
+ if (!cachedRoot || cachedRoot.freeSlots < rootMinFreeSlots) {
4462
+ const refreshed = await this.queryTrackers(ch, options.trackerPeers ?? [], Math.max(16, ch.cachedTrackerCandidates.length), parentUpgrade.probe.timeoutMs, options.signal).catch(() => []);
4463
+ if (refreshed.length > 0) {
4464
+ ch.cachedTrackerCandidates = refreshed;
4465
+ }
4466
+ }
4467
+ }
4468
+ const configuredRejectCooldownMs = Math.max(0, parentUpgrade.probe.rejectCooldownMs);
4469
+ const rejectCooldownMs = localChannelCount > 1
4470
+ ? Math.max(configuredRejectCooldownMs, 20_000)
4471
+ : configuredRejectCooldownMs;
4472
+ const rejectCooldownMaxMs = Math.max(rejectCooldownMs, parentUpgrade.probe.rejectCooldownMaxMs);
4473
+ const clearProbeRejectBackoff = (candidateHash) => {
4474
+ ch.parentProbeRejectUntilByHash.delete(candidateHash);
4475
+ ch.parentProbeRejectBackoffMsByHash.delete(candidateHash);
4476
+ };
4477
+ const rejectProbeCandidate = (candidateHash) => {
4478
+ if (rejectCooldownMs <= 0)
4479
+ return;
4480
+ const nextBackoffMs = (ch.parentProbeRejectBackoffMsByHash.get(candidateHash) ?? 0) > 0
4481
+ ? Math.min(rejectCooldownMaxMs, Math.max(rejectCooldownMs, (ch.parentProbeRejectBackoffMsByHash.get(candidateHash) ?? 0) *
4482
+ 2))
4483
+ : rejectCooldownMs;
4484
+ ch.parentProbeRejectBackoffMsByHash.set(candidateHash, nextBackoffMs);
4485
+ const jitter = 0.75 +
4486
+ upgradeRandom(`probe-reject:${candidateHash}:${nextBackoffMs}`) * 0.5;
4487
+ ch.parentProbeRejectUntilByHash.set(candidateHash, Date.now() + Math.max(1, Math.floor(nextBackoffMs * jitter)));
4488
+ while (ch.parentProbeRejectBackoffMsByHash.size >
4489
+ KNOWN_CANDIDATE_ADDRS_MAX_ENTRIES) {
4490
+ const oldest = ch.parentProbeRejectBackoffMsByHash.keys().next()
4491
+ .value;
4492
+ if (!oldest)
4493
+ break;
4494
+ ch.parentProbeRejectBackoffMsByHash.delete(oldest);
4495
+ ch.parentProbeRejectUntilByHash.delete(oldest);
4496
+ }
4497
+ while (ch.parentProbeRejectUntilByHash.size > KNOWN_CANDIDATE_ADDRS_MAX_ENTRIES) {
4498
+ const oldest = ch.parentProbeRejectUntilByHash.keys().next().value;
4499
+ if (!oldest)
4500
+ break;
4501
+ ch.parentProbeRejectUntilByHash.delete(oldest);
4502
+ ch.parentProbeRejectBackoffMsByHash.delete(oldest);
4503
+ }
4504
+ };
4505
+ const minFreeSlotsFor = (hash) => hash === ch.id.root
4506
+ ? parentUpgrade.rootMinFreeSlots
4507
+ : parentUpgrade.minFreeSlots;
4508
+ const refreshCachedTrackerCandidate = (hash, level, freeSlots) => {
4509
+ const cached = ch.cachedTrackerCandidates.find((c) => c.hash === hash);
4510
+ if (!cached)
4511
+ return;
4512
+ if (Number.isFinite(level))
4513
+ cached.level = Math.max(0, Math.floor(level));
4514
+ if (Number.isFinite(freeSlots)) {
4515
+ cached.freeSlots = Math.max(0, Math.floor(freeSlots));
4516
+ }
4517
+ };
4518
+ const sendProbeTrackerFeedback = (candidateHash, reason) => {
4519
+ const trackerPeers = options.trackerPeers ?? [];
4520
+ if (trackerPeers.length === 0)
4521
+ return;
4522
+ void this.sendTrackerFeedback(ch, trackerPeers, candidateHash, TRACKER_FEEDBACK_JOIN_REJECT, reason).catch(() => { });
4523
+ };
4524
+ for (const c of ch.cachedTrackerCandidates) {
4525
+ if (c.hash === this.publicKeyHash)
4526
+ continue;
4527
+ if (!isDirectNeighbor(c.hash))
4528
+ continue;
4529
+ upsertCandidate({
4530
+ hash: c.hash,
4531
+ addrs: c.addrs,
4532
+ level: c.level,
4533
+ freeSlots: Number.isFinite(c.freeSlots)
4534
+ ? Math.max(0, Math.floor(c.freeSlots))
4535
+ : 0,
4536
+ bidPerByte: c.bidPerByte,
4537
+ source: 0,
4538
+ });
4539
+ }
4540
+ if (ch.id.root !== this.publicKeyHash &&
4541
+ isDirectNeighbor(ch.id.root) &&
4542
+ !candidatesByHash.has(ch.id.root)) {
4543
+ upsertCandidate({
4544
+ hash: ch.id.root,
4545
+ addrs: [],
4546
+ level: 0,
4547
+ freeSlots: 1,
4548
+ bidPerByte: 0,
4549
+ source: -1,
4550
+ });
4551
+ }
4552
+ let connectedFallbackAdded = 0;
4553
+ const connectedFallbackMax = 64;
4554
+ for (const h of this.peers.keys()) {
4555
+ if (h === this.publicKeyHash)
4556
+ continue;
4557
+ if (!isDirectNeighbor(h))
4558
+ continue;
4559
+ upsertCandidate({
4560
+ hash: h,
4561
+ addrs: [],
4562
+ level: 0xffff,
4563
+ freeSlots: 0,
4564
+ bidPerByte: 0,
4565
+ source: 2,
4566
+ });
4567
+ connectedFallbackAdded += 1;
4568
+ if (connectedFallbackAdded >= connectedFallbackMax)
4569
+ break;
4570
+ }
4571
+ const orderedCandidates = [];
4572
+ for (const c of candidatesByHash.values()) {
4573
+ if (c.hash === this.publicKeyHash || c.hash === currentParent)
4574
+ continue;
4575
+ const canVerifyRootCapacityLive = parentUpgrade.verifyStaleRootCapacity === true &&
4576
+ parentUpgrade.mode === "shadow" &&
4577
+ c.hash === ch.id.root;
4578
+ const requiredMinFreeSlots = minFreeSlotsFor(c.hash);
4579
+ if (c.hash === ch.id.root &&
4580
+ defaultMultiChannelSettledLeafRootCandidate &&
4581
+ !defaultMultiChannelLeafRootSignal) {
4582
+ ch.metrics.reparentUpgradeSkipCandidateSlots += 1;
4583
+ continue;
4584
+ }
4585
+ if (canVerifyRootCapacityLive && c.freeSlots < requiredMinFreeSlots) {
4586
+ const staleRootProbeProbability = (() => {
4587
+ if (singleChannelBranch && staleRootProbeBaseProbability > 0) {
4588
+ const highValueBranch = rootSubtreeGainFor(c.level) >=
4589
+ Math.max(rootMinSubtreeGain + 1, 4);
4590
+ const branchCap = highValueBranch ? 0.25 : 0.2;
4591
+ return Math.max(staleRootProbeBaseProbability, Math.min(branchCap, staleRootProbeBaseProbability * 8));
4592
+ }
4593
+ if (multiChannelBranch && staleRootProbeBaseProbability > 0) {
4594
+ return Math.max(staleRootProbeBaseProbability, Math.min(0.03125, staleRootProbeBaseProbability * 2));
4595
+ }
4596
+ if (singleChannelSettledLeaf && staleRootProbeBaseProbability > 0) {
4597
+ return 0.5;
4598
+ }
4599
+ if (multiChannelSettledLeaf && parentUpgrade.leafOnly) {
4600
+ return staleRootProbeBaseProbability >
4601
+ defaultStaleRootProbeProbability
4602
+ ? Math.max(staleRootProbeBaseProbability, Math.min(0.125, 3 / knownChannelPeerCount))
4603
+ : 0;
4604
+ }
4605
+ return Math.min(1, staleRootProbeBaseProbability *
4606
+ Math.max(1, Math.min(4, staleRootProbeRound + 1)));
4607
+ })();
4608
+ const staleRootProbeScore = stableUnitInterval(`${ch.id.suffixKey}:${this.publicKeyHash}:${c.hash}:stale-root-probe:${staleRootProbeRound}`);
4609
+ if (staleRootProbeScore >= staleRootProbeProbability) {
4610
+ ch.metrics.reparentUpgradeSkipCandidateSlots += 1;
4611
+ continue;
4612
+ }
4613
+ }
4614
+ if (!canVerifyRootCapacityLive && c.freeSlots < requiredMinFreeSlots) {
4615
+ ch.metrics.reparentUpgradeSkipCandidateSlots += 1;
4616
+ continue;
4617
+ }
4618
+ if (!isEnoughLevelGain(c.hash, c.level)) {
4619
+ ch.metrics.reparentUpgradeSkipCandidateLevel += 1;
4620
+ continue;
4621
+ }
4622
+ orderedCandidates.push(c);
4623
+ }
4624
+ let ordered = orderedCandidates.sort((a, b) => {
4625
+ if (a.level !== b.level)
4626
+ return a.level - b.level;
4627
+ if (a.freeSlots !== b.freeSlots)
4628
+ return b.freeSlots - a.freeSlots;
4629
+ if (a.bidPerByte !== b.bidPerByte)
4630
+ return b.bidPerByte - a.bidPerByte;
4631
+ if (a.source !== b.source)
4632
+ return a.source - b.source;
4633
+ return a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0;
4634
+ });
4635
+ if (ordered.length === 0) {
4636
+ if (parentUpgrade.mode === "probe" || parentUpgrade.mode === "shadow") {
4637
+ applyFailedBackoff();
4638
+ }
4639
+ return false;
4640
+ }
4641
+ if (options.candidateScoringMode === "ranked-shuffle") {
4642
+ if (options.candidateShuffleTopK > 0 && ordered.length > 1) {
4643
+ const k = Math.min(options.candidateShuffleTopK, ordered.length);
4644
+ const shuffleScope = ordered.map((c) => c.hash).join(",");
4645
+ for (let i = k - 1; i > 0; i--) {
4646
+ const j = Math.floor(upgradeRandom(`shuffle:${shuffleScope}:${i}`) * (i + 1));
4647
+ const tmp = ordered[i];
4648
+ ordered[i] = ordered[j];
4649
+ ordered[j] = tmp;
4650
+ }
4651
+ }
4652
+ }
4653
+ else if (options.candidateScoringMode === "weighted") {
4654
+ const wLevel = Number.isFinite(options.candidateScoringWeights.level)
4655
+ ? Math.max(0, options.candidateScoringWeights.level)
4656
+ : 0;
4657
+ const wSlots = Number.isFinite(options.candidateScoringWeights.freeSlots)
4658
+ ? Math.max(0, options.candidateScoringWeights.freeSlots)
4659
+ : 0;
4660
+ const wConnected = Number.isFinite(options.candidateScoringWeights.connected)
4661
+ ? Math.max(0, options.candidateScoringWeights.connected)
4662
+ : 0;
4663
+ const wBid = Number.isFinite(options.candidateScoringWeights.bidPerByte)
4664
+ ? Math.max(0, options.candidateScoringWeights.bidPerByte)
4665
+ : 0;
4666
+ const wSource = Number.isFinite(options.candidateScoringWeights.source)
4667
+ ? Math.max(0, options.candidateScoringWeights.source)
4668
+ : 0;
4669
+ const k = options.candidateShuffleTopK > 0
4670
+ ? Math.min(options.candidateShuffleTopK, ordered.length)
4671
+ : 0;
4672
+ const hasWeightedSignal = wLevel > 0 || wSlots > 0 || wConnected > 0 || wBid > 0 || wSource > 0;
4673
+ if (k > 1 && hasWeightedSignal) {
4674
+ const head = ordered.slice(0, k);
4675
+ const tail = ordered.slice(k);
4676
+ const weightOf = (c) => {
4677
+ const level = Math.max(0, Math.floor(c.level));
4678
+ const freeSlots = Math.max(0, Math.floor(c.freeSlots));
4679
+ const bidPerByte = Math.max(0, Math.floor(c.bidPerByte));
4680
+ const source = Math.max(0, Math.floor(c.source));
4681
+ const connected = Boolean(this.peers.get(c.hash));
4682
+ let weight = 1;
4683
+ if (wLevel > 0)
4684
+ weight *= 1 / (1 + wLevel * level);
4685
+ if (wSlots > 0)
4686
+ weight *= 1 + wSlots * freeSlots;
4687
+ if (wConnected > 0 && connected)
4688
+ weight *= 1 + wConnected;
4689
+ if (wBid > 0)
4690
+ weight *= 1 + wBid * bidPerByte;
4691
+ if (wSource > 0)
4692
+ weight *= 1 / (1 + wSource * source);
4693
+ return weight;
4694
+ };
4695
+ const out = [];
4696
+ const remaining = [...head];
4697
+ while (remaining.length > 0) {
4698
+ let sum = 0;
4699
+ const weights = new Array(remaining.length);
4700
+ for (let i = 0; i < remaining.length; i++) {
4701
+ const w = weightOf(remaining[i]);
4702
+ const v = Number.isFinite(w) ? Math.max(0, w) : 0;
4703
+ weights[i] = v;
4704
+ sum += v;
4705
+ }
4706
+ if (sum <= 0) {
4707
+ out.push(...remaining);
4708
+ break;
4709
+ }
4710
+ let r = upgradeRandom(`weighted:${out.length}:${remaining
4711
+ .map((candidate) => candidate.hash)
4712
+ .join(",")}`) * sum;
4713
+ let pick = 0;
4714
+ for (; pick < weights.length; pick++) {
4715
+ r -= weights[pick];
4716
+ if (r <= 0)
4717
+ break;
4718
+ }
4719
+ if (pick >= remaining.length)
4720
+ pick = remaining.length - 1;
4721
+ out.push(remaining[pick]);
4722
+ remaining.splice(pick, 1);
4723
+ }
4724
+ ordered = out.concat(tail);
4725
+ }
4726
+ }
4727
+ if (parentUpgrade.mode === "probe" || parentUpgrade.mode === "shadow") {
4728
+ const mode = parentUpgrade.mode;
4729
+ const probeLimit = parentUpgrade.mode === "shadow"
4730
+ ? 1
4731
+ : parentUpgrade.probe.maxPerRound;
4732
+ const shadowObserveMsForGate = parentUpgrade.shadow.observeMs;
4733
+ const shadowMinObservationsForGate = parentUpgrade.shadow.minObservations;
4734
+ const probeTimeoutMs = parentUpgrade.probe.timeoutMs;
4735
+ const maxLag = parentUpgrade.probe.maxLagMessages;
4736
+ const now = Date.now();
4737
+ const candidatesToProbe = [];
4738
+ const shadowCandidate = mode === "shadow" && ch.parentShadow
4739
+ ? ordered.find((candidate) => candidate.hash === ch.parentShadow?.hash)
4740
+ : undefined;
4741
+ if (mode === "shadow" &&
4742
+ shadowCandidate &&
4743
+ ch.parentShadow &&
4744
+ shadowObserveMsForGate > 0 &&
4745
+ now - ch.parentShadow.startedAt < shadowObserveMsForGate) {
4746
+ return false;
4747
+ }
4748
+ const probeOrder = shadowCandidate
4749
+ ? [
4750
+ shadowCandidate,
4751
+ ...ordered.filter((candidate) => candidate.hash !== shadowCandidate.hash),
4752
+ ]
4753
+ : ordered;
4754
+ for (const candidate of probeOrder) {
4755
+ const rejectUntil = ch.parentProbeRejectUntilByHash.get(candidate.hash);
4756
+ if (rejectUntil != null && rejectUntil > now) {
4757
+ ch.metrics.reparentUpgradeSkipProbeCooldown += 1;
4758
+ continue;
4759
+ }
4760
+ if (rejectUntil != null)
4761
+ ch.parentProbeRejectUntilByHash.delete(candidate.hash);
4762
+ candidatesToProbe.push(candidate);
4763
+ if (candidatesToProbe.length >= probeLimit)
4764
+ break;
4765
+ }
4766
+ if (candidatesToProbe.length === 0) {
4767
+ applyFailedBackoff();
4768
+ return false;
4769
+ }
4770
+ const shouldReserveRootCapacityForProbe = (candidate) => {
4771
+ if (candidate.hash !== ch.id.root)
4772
+ return true;
4773
+ if (mode !== "shadow")
4774
+ return true;
4775
+ if (endedAndComplete && candidate.level === 0)
4776
+ return true;
4777
+ if (!ch.parentShadow || ch.parentShadow.hash !== candidate.hash) {
4778
+ return (shadowObserveMsForGate <= 0 && shadowMinObservationsForGate <= 1);
4779
+ }
4780
+ return (now - ch.parentShadow.startedAt >= shadowObserveMsForGate &&
4781
+ ch.parentShadow.observations + 1 >= shadowMinObservationsForGate);
4782
+ };
4783
+ const rejectShadowCandidate = (candidateHash, reason) => {
4784
+ if (mode !== "shadow")
4785
+ return;
4786
+ switch (reason) {
4787
+ case "noReply":
4788
+ ch.metrics.parentShadowRejectNoReply += 1;
4789
+ break;
4790
+ case "notRooted":
4791
+ ch.metrics.parentShadowRejectNotRooted += 1;
4792
+ break;
4793
+ case "capacity":
4794
+ ch.metrics.parentShadowRejectCapacity += 1;
4795
+ break;
4796
+ case "repair":
4797
+ ch.metrics.parentShadowRejectRepair += 1;
4798
+ break;
4799
+ case "lag":
4800
+ ch.metrics.parentShadowRejectLag += 1;
4801
+ break;
4802
+ case "overloaded":
4803
+ ch.metrics.parentShadowRejectOverloaded += 1;
4804
+ break;
4805
+ case "level":
4806
+ ch.metrics.parentShadowRejectLevel += 1;
4807
+ break;
4808
+ }
4809
+ if (ch.parentShadow?.hash === candidateHash)
4810
+ resetShadow();
4811
+ deferParentUpgradeRetryUntilFreshData();
4812
+ };
4813
+ const probed = await Promise.all(candidatesToProbe.map(async (candidate) => ({
4814
+ candidate,
4815
+ reply: await this.probeParentCandidate(ch, candidate.hash, probeTimeoutMs, options.signal, minFreeSlotsFor(candidate.hash), shouldReserveRootCapacityForProbe(candidate)),
4816
+ })));
4817
+ const liveCandidates = [];
4818
+ for (const { candidate, reply } of probed) {
4819
+ if (!reply) {
4820
+ ch.metrics.reparentUpgradeSkipProbeNoReply += 1;
4821
+ rejectShadowCandidate(candidate.hash, "noReply");
4822
+ rejectProbeCandidate(candidate.hash);
4823
+ continue;
4824
+ }
4825
+ if (!reply.rooted) {
4826
+ ch.metrics.reparentUpgradeSkipProbeNotRooted += 1;
4827
+ refreshCachedTrackerCandidate(candidate.hash, reply.level, 0);
4828
+ sendProbeTrackerFeedback(candidate.hash, JOIN_REJECT_NOT_ATTACHED);
4829
+ rejectShadowCandidate(candidate.hash, "notRooted");
4830
+ rejectProbeCandidate(candidate.hash);
4831
+ continue;
4832
+ }
4833
+ const liveLevel = Number.isFinite(reply.level)
4834
+ ? Math.max(0, Math.floor(reply.level))
4835
+ : candidate.level;
4836
+ const liveFreeSlots = Number.isFinite(reply.freeSlots)
4837
+ ? Math.max(0, Math.floor(reply.freeSlots))
4838
+ : 0;
4839
+ refreshCachedTrackerCandidate(candidate.hash, liveLevel, liveFreeSlots);
4840
+ const requiredMinFreeSlots = minFreeSlotsFor(candidate.hash);
4841
+ if (!reply.accepting || liveFreeSlots < requiredMinFreeSlots) {
4842
+ ch.metrics.reparentUpgradeSkipCandidateSlots += 1;
4843
+ if (!reply.accepting || liveFreeSlots <= 0) {
4844
+ sendProbeTrackerFeedback(candidate.hash, JOIN_REJECT_NO_CAPACITY);
4845
+ }
4846
+ rejectShadowCandidate(candidate.hash, "capacity");
4847
+ rejectProbeCandidate(candidate.hash);
4848
+ continue;
4849
+ }
4850
+ const configuredMaxChildLoadRatioRaw = candidate.hash === ch.id.root
4851
+ ? parentUpgrade.rootMaxChildLoadRatio
4852
+ : parentUpgrade.maxChildLoadRatio;
4853
+ const configuredMaxChildLoadRatio = candidate.hash === ch.id.root &&
4854
+ localChannelCount > 1 &&
4855
+ Number(configuredMaxChildLoadRatioRaw) > 0
4856
+ ? Math.min(0.2, Number(configuredMaxChildLoadRatioRaw))
4857
+ : configuredMaxChildLoadRatioRaw;
4858
+ const maxChildLoadRatio = Math.max(0, Number(configuredMaxChildLoadRatio));
4859
+ if (maxChildLoadRatio > 0 &&
4860
+ reply.maxChildren > 0 &&
4861
+ (reply.children + 1) / reply.maxChildren > maxChildLoadRatio) {
4862
+ ch.metrics.reparentUpgradeSkipCandidateSlots += 1;
4863
+ ch.metrics.reparentUpgradeSkipCandidatePressure += 1;
4864
+ if (candidate.hash === ch.id.root) {
4865
+ ch.metrics.reparentUpgradeSkipRootPressure += 1;
4866
+ refreshCachedTrackerCandidate(candidate.hash, liveLevel, 0);
4867
+ sendProbeTrackerFeedback(candidate.hash, JOIN_REJECT_NO_CAPACITY);
4868
+ }
4869
+ rejectShadowCandidate(candidate.hash, "capacity");
4870
+ rejectProbeCandidate(candidate.hash);
4871
+ continue;
4872
+ }
4873
+ if (reply.repairing || reply.missingSeqs > 0) {
4874
+ ch.metrics.reparentUpgradeSkipProbeRepair += 1;
4875
+ rejectShadowCandidate(candidate.hash, "repair");
4876
+ rejectProbeCandidate(candidate.hash);
4877
+ continue;
4878
+ }
4879
+ if (reply.overloaded) {
4880
+ ch.metrics.reparentUpgradeSkipProbeOverloaded += 1;
4881
+ rejectShadowCandidate(candidate.hash, "overloaded");
4882
+ rejectProbeCandidate(candidate.hash);
4883
+ continue;
4884
+ }
4885
+ if (ch.maxSeqSeen >= 0 &&
4886
+ reply.haveToExclusive + maxLag < ch.maxSeqSeen + 1) {
4887
+ ch.metrics.reparentUpgradeSkipProbeLag += 1;
4888
+ rejectShadowCandidate(candidate.hash, "lag");
4889
+ rejectProbeCandidate(candidate.hash);
4890
+ continue;
4891
+ }
4892
+ if (!isEnoughLevelGain(candidate.hash, liveLevel)) {
4893
+ ch.metrics.reparentUpgradeSkipCandidateLevel += 1;
4894
+ rejectShadowCandidate(candidate.hash, "level");
4895
+ rejectProbeCandidate(candidate.hash);
4896
+ continue;
4897
+ }
4898
+ clearProbeRejectBackoff(candidate.hash);
4899
+ liveCandidates.push({
4900
+ ...candidate,
4901
+ level: liveLevel,
4902
+ freeSlots: liveFreeSlots,
4903
+ haveToExclusive: reply.haveToExclusive,
4904
+ reservationToken: reply.reservationToken,
4905
+ });
4906
+ }
4907
+ ordered = liveCandidates.sort((a, b) => {
4908
+ if (a.level !== b.level)
4909
+ return a.level - b.level;
4910
+ if (a.freeSlots !== b.freeSlots)
4911
+ return b.freeSlots - a.freeSlots;
4912
+ if (a.bidPerByte !== b.bidPerByte)
4913
+ return b.bidPerByte - a.bidPerByte;
4914
+ if (a.source !== b.source)
4915
+ return a.source - b.source;
4916
+ return a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0;
4917
+ });
4918
+ if (ordered.length === 0) {
4919
+ applyFailedBackoff();
4920
+ return false;
4921
+ }
4922
+ clearFailedBackoff();
4923
+ if (mode === "shadow") {
4924
+ const nowAfterProbe = Date.now();
4925
+ const observeMs = parentUpgrade.shadow.observeMs;
4926
+ const minObservations = shadowMinObservationsForGate;
4927
+ const requireDataForShadowPromotion = parentUpgrade.shadow.dualPathMs > 0;
4928
+ const selected = ch.parentShadow != null
4929
+ ? (ordered.find((candidate) => candidate.hash === ch.parentShadow?.hash) ?? ordered[0])
4930
+ : ordered[0];
4931
+ if (!selected) {
4932
+ resetShadow();
4933
+ return false;
4934
+ }
4935
+ if (endedAndComplete &&
4936
+ selected.hash === ch.id.root &&
4937
+ selected.level === 0 &&
4938
+ !requireDataForShadowPromotion) {
4939
+ if (!ch.parentShadow || ch.parentShadow.hash !== selected.hash) {
4940
+ ch.parentShadow = {
4941
+ hash: selected.hash,
4942
+ startedAt: nowAfterProbe - observeMs,
4943
+ observations: minObservations,
4944
+ level: selected.level,
4945
+ freeSlots: selected.freeSlots,
4946
+ haveToExclusive: "haveToExclusive" in selected &&
4947
+ typeof selected.haveToExclusive === "number"
4948
+ ? selected.haveToExclusive
4949
+ : 0,
4950
+ liveDataMessages: 0,
4951
+ liveFirstDataMessages: 0,
4952
+ liveParentFirstDataMessages: 0,
4953
+ liveCandidateLeadSamples: 0,
4954
+ liveCandidateLeadMsTotal: 0,
4955
+ liveCandidateFirstAtBySeq: new Map(),
4956
+ liveParentFirstAtBySeq: new Map(),
4957
+ liveComparedSeqs: new Set(),
4958
+ liveMaxSeqSeen: -1,
4959
+ liveLastDataAt: 0,
4960
+ };
4961
+ this.parentUpgradeShadowInFlightSuffixKey = ch.id.suffixKey;
4962
+ ch.metrics.parentShadowStart += 1;
4963
+ }
4964
+ else {
4965
+ ch.parentShadow.observations += 1;
4966
+ ch.parentShadow.level = selected.level;
4967
+ ch.parentShadow.freeSlots = selected.freeSlots;
4968
+ ch.parentShadow.haveToExclusive =
4969
+ "haveToExclusive" in selected &&
4970
+ typeof selected.haveToExclusive === "number"
4971
+ ? selected.haveToExclusive
4972
+ : 0;
4973
+ ch.metrics.parentShadowObserve += 1;
4974
+ }
4975
+ ordered = [selected];
4976
+ }
4977
+ else {
4978
+ if (!ch.parentShadow || ch.parentShadow.hash !== selected.hash) {
4979
+ ch.parentShadow = {
4980
+ hash: selected.hash,
4981
+ startedAt: nowAfterProbe,
4982
+ observations: 1,
4983
+ level: selected.level,
4984
+ freeSlots: selected.freeSlots,
4985
+ haveToExclusive: "haveToExclusive" in selected &&
4986
+ typeof selected.haveToExclusive === "number"
4987
+ ? selected.haveToExclusive
4988
+ : 0,
4989
+ liveDataMessages: 0,
4990
+ liveFirstDataMessages: 0,
4991
+ liveParentFirstDataMessages: 0,
4992
+ liveCandidateLeadSamples: 0,
4993
+ liveCandidateLeadMsTotal: 0,
4994
+ liveCandidateFirstAtBySeq: new Map(),
4995
+ liveParentFirstAtBySeq: new Map(),
4996
+ liveComparedSeqs: new Set(),
4997
+ liveMaxSeqSeen: -1,
4998
+ liveLastDataAt: 0,
4999
+ };
5000
+ this.parentUpgradeShadowInFlightSuffixKey = ch.id.suffixKey;
5001
+ ch.metrics.parentShadowStart += 1;
5002
+ return false;
5003
+ }
5004
+ ch.parentShadow.observations += 1;
5005
+ ch.parentShadow.level = selected.level;
5006
+ ch.parentShadow.freeSlots = selected.freeSlots;
5007
+ ch.parentShadow.haveToExclusive =
5008
+ "haveToExclusive" in selected &&
5009
+ typeof selected.haveToExclusive === "number"
5010
+ ? selected.haveToExclusive
5011
+ : 0;
5012
+ ch.metrics.parentShadowObserve += 1;
5013
+ if (ch.parentShadow.observations < minObservations ||
5014
+ nowAfterProbe - ch.parentShadow.startedAt < observeMs) {
5015
+ return false;
5016
+ }
5017
+ }
5018
+ ordered = [selected];
5019
+ }
5020
+ }
5021
+ let attempts = 0;
5022
+ for (const candidate of ordered) {
5023
+ if (options.signal.aborted)
5024
+ break;
5025
+ if (attempts >= options.joinAttemptsPerRound)
5026
+ break;
5027
+ attempts += 1;
5028
+ if (!isDirectNeighbor(candidate.hash))
5029
+ continue;
5030
+ const previousParent = ch.parent;
5031
+ const previousLevel = ch.level;
5032
+ const previousRouteFromRoot = ch.routeFromRoot
5033
+ ? [...ch.routeFromRoot]
5034
+ : undefined;
5035
+ const previousLastParentDataAt = ch.lastParentDataAt;
5036
+ const previousLastParentUpgradeActivityAt = ch.lastParentUpgradeActivityAt;
5037
+ const previousReceivedAnyParentData = ch.receivedAnyParentData;
5038
+ const shadowDualPathMs = parentUpgrade.shadow.dualPathMs;
5039
+ const shadowDualPathMinMessages = parentUpgrade.shadow.dualPathMinMessages;
5040
+ const shadowDualPathMinAdvantageMessages = Math.max(1, Math.min(16, Math.ceil(shadowDualPathMinMessages / 2)));
5041
+ const shadowDualPathMinLeadSamples = Math.max(1, Math.min(32, shadowDualPathMinMessages));
5042
+ const shadowDualPathMaxParentFirstMessages = Math.max(0, Math.floor(shadowDualPathMinMessages / 8));
5043
+ const shadowDualPathMinLeadMsAvg = shadowDualPathMinMessages <= 1 ? 0 : 128;
5044
+ const useShadowDualPath = parentUpgrade.mode === "shadow" &&
5045
+ shadowDualPathMs > 0 &&
5046
+ previousParent != null &&
5047
+ previousParent !== candidate.hash;
5048
+ const seqAtShadowAttach = ch.maxSeqSeen;
5049
+ const reqId = (this.random() * 0xffffffff) >>> 0;
5050
+ const res = await this.tryJoinOnce(ch, candidate.hash, reqId, options.joinReqTimeoutMs, options.signal, {
5051
+ allowReplace: true,
5052
+ parentUpgradeReservationToken: candidate.hash === ch.id.root &&
5053
+ "reservationToken" in candidate &&
5054
+ typeof candidate.reservationToken === "number"
5055
+ ? candidate.reservationToken
5056
+ : 0,
5057
+ shadowAttach: useShadowDualPath,
5058
+ });
5059
+ if (res.ok) {
5060
+ if (useShadowDualPath) {
5061
+ const acceptedParentLevel = Number.isFinite(res.parentLevel)
5062
+ ? Math.max(0, Math.floor(res.parentLevel))
5063
+ : candidate.level;
5064
+ if (!isEnoughLevelGain(candidate.hash, acceptedParentLevel)) {
5065
+ void this._sendControl(candidate.hash, this.codec.encodeLeave(ch.id.key)).catch(() => { });
5066
+ resetShadow();
5067
+ rejectProbeCandidate(candidate.hash);
5068
+ deferParentUpgradeRetryUntilFreshData(2);
5069
+ applyFailedBackoff();
5070
+ return false;
5071
+ }
5072
+ const shadow = ch.parentShadow;
5073
+ if (shadow?.hash === candidate.hash) {
5074
+ shadow.liveDataMessages = 0;
5075
+ shadow.liveFirstDataMessages = 0;
5076
+ shadow.liveParentFirstDataMessages = 0;
5077
+ shadow.liveCandidateLeadSamples = 0;
5078
+ shadow.liveCandidateLeadMsTotal = 0;
5079
+ shadow.liveCandidateFirstAtBySeq.clear();
5080
+ shadow.liveParentFirstAtBySeq.clear();
5081
+ shadow.liveComparedSeqs.clear();
5082
+ shadow.liveMaxSeqSeen = seqAtShadowAttach;
5083
+ shadow.liveLastDataAt = 0;
5084
+ }
5085
+ const deadline = Date.now() + shadowDualPathMs;
5086
+ for (;;) {
5087
+ if (options.signal.aborted)
5088
+ break;
5089
+ const currentShadow = ch.parentShadow;
5090
+ const observedFreshCandidateData = currentShadow?.hash === candidate.hash &&
5091
+ currentShadow.liveFirstDataMessages >=
5092
+ shadowDualPathMinMessages &&
5093
+ currentShadow.liveParentFirstDataMessages <=
5094
+ shadowDualPathMaxParentFirstMessages &&
5095
+ currentShadow.liveFirstDataMessages >=
5096
+ currentShadow.liveParentFirstDataMessages +
5097
+ shadowDualPathMinAdvantageMessages &&
5098
+ currentShadow.liveCandidateLeadSamples >=
5099
+ shadowDualPathMinLeadSamples &&
5100
+ currentShadow.liveCandidateLeadMsTotal >=
5101
+ currentShadow.liveCandidateLeadSamples *
5102
+ shadowDualPathMinLeadMsAvg &&
5103
+ currentShadow.liveMaxSeqSeen > seqAtShadowAttach;
5104
+ if (observedFreshCandidateData) {
5105
+ const parentRoute = res.parentRouteFromRoot;
5106
+ if (!parentRoute || parentRoute.length === 0)
5107
+ break;
5108
+ ch.parent = candidate.hash;
5109
+ ch.level = Math.max(1, Math.floor(res.parentLevel ?? candidate.level) + 1);
5110
+ ch.routeFromRoot = [...parentRoute, this.publicKeyHash];
5111
+ ch.joinedAtLeastOnce = true;
5112
+ ch.lastParentDataAt =
5113
+ currentShadow.liveLastDataAt > 0
5114
+ ? currentShadow.liveLastDataAt
5115
+ : Date.now();
5116
+ ch.lastParentUpgradeActivityAt = ch.lastParentDataAt;
5117
+ ch.receivedAnyParentData = true;
5118
+ this.resetParentDataLatency(ch);
5119
+ this.touchPeerHint(ch, candidate.hash);
5120
+ if (previousParent) {
5121
+ this.startParentUpgradeGrace(ch, {
5122
+ previousParent,
5123
+ candidateParent: candidate.hash,
5124
+ previousLevel,
5125
+ previousRouteFromRoot,
5126
+ previousLastParentDataAt,
5127
+ previousLastParentUpgradeActivityAt,
5128
+ previousReceivedAnyParentData,
5129
+ candidateFirstMessages: 0,
5130
+ previousFirstMessages: 0,
5131
+ minCandidateFirstMessages: shadowDualPathMinMessages,
5132
+ minCandidateAdvantageMessages: shadowDualPathMinAdvantageMessages,
5133
+ maxPreviousFirstMessages: shadowDualPathMaxParentFirstMessages,
5134
+ timeoutMs: shadowDualPathMs,
5135
+ });
5136
+ }
5137
+ ch.metrics.parentShadowPromote += 1;
5138
+ ch.parentShadow = undefined;
5139
+ clearShadowInFlight();
5140
+ clearFailedBackoff();
5141
+ void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
5142
+ return true;
5143
+ }
5144
+ const remainingMs = deadline - Date.now();
5145
+ if (remainingMs <= 0)
5146
+ break;
5147
+ await delay(Math.min(50, remainingMs), {
5148
+ signal: options.signal,
5149
+ });
5150
+ }
5151
+ if (ch.parent === previousParent) {
5152
+ ch.level = previousLevel;
5153
+ ch.routeFromRoot = previousRouteFromRoot;
5154
+ ch.lastParentDataAt = previousLastParentDataAt;
5155
+ ch.lastParentUpgradeActivityAt =
5156
+ previousLastParentUpgradeActivityAt;
5157
+ ch.receivedAnyParentData = previousReceivedAnyParentData;
5158
+ }
5159
+ void this._sendControl(candidate.hash, this.codec.encodeLeave(ch.id.key)).catch(() => { });
5160
+ if (ch.parentShadow) {
5161
+ ch.parentShadow = undefined;
5162
+ clearShadowInFlight();
5163
+ ch.metrics.parentShadowReset += 1;
5164
+ }
5165
+ // A shadow cutover can only be proven by fresh data. If no
5166
+ // candidate-first data arrived, wait for enough newer data before
5167
+ // spending more probes on the same channel. Also cool down this
5168
+ // candidate; accepting JOIN without proving live data is a weak
5169
+ // parent signal, not a reason to probe it repeatedly.
5170
+ rejectProbeCandidate(candidate.hash);
5171
+ deferParentUpgradeRetryUntilFreshData(2);
5172
+ applyFailedBackoff();
5173
+ return false;
5174
+ }
5175
+ const newParent = ch.parent;
5176
+ if (previousParent && newParent && newParent !== previousParent) {
5177
+ void this._sendControl(previousParent, this.codec.encodeLeave(ch.id.key)).catch(() => { });
5178
+ if (parentUpgrade.mode === "shadow") {
5179
+ ch.metrics.parentShadowPromote += 1;
5180
+ ch.parentShadow = undefined;
5181
+ clearShadowInFlight();
5182
+ clearFailedBackoff();
5183
+ }
5184
+ return true;
5185
+ }
5186
+ return false;
5187
+ }
5188
+ if (useShadowDualPath) {
5189
+ if (ch.parent === previousParent) {
5190
+ ch.level = previousLevel;
5191
+ ch.routeFromRoot = previousRouteFromRoot;
5192
+ ch.lastParentDataAt = previousLastParentDataAt;
5193
+ ch.lastParentUpgradeActivityAt =
5194
+ previousLastParentUpgradeActivityAt;
5195
+ ch.receivedAnyParentData = previousReceivedAnyParentData;
5196
+ }
5197
+ if (ch.parentShadow?.hash === candidate.hash) {
5198
+ ch.parentShadow = undefined;
5199
+ clearShadowInFlight();
5200
+ ch.metrics.parentShadowReset += 1;
5201
+ }
5202
+ rejectProbeCandidate(candidate.hash);
5203
+ deferParentUpgradeRetryUntilFreshData(2);
5204
+ applyFailedBackoff();
5205
+ return false;
5206
+ }
5207
+ }
5208
+ return false;
5209
+ }
5210
+ async tryJoinOnce(ch, parentHash, reqId, timeoutMs, signal, options) {
5211
+ if (ch.parent && options?.allowReplace !== true)
4153
5212
  return { ok: true };
4154
5213
  if (!this.peers.get(parentHash))
4155
5214
  return { ok: false, timedOut: true };
4156
5215
  const p = new Promise((resolve) => {
4157
- ch.pendingJoin.set(reqId, { resolve });
5216
+ ch.pendingJoin.set(reqId, {
5217
+ resolve,
5218
+ shadowAttach: options?.shadowAttach === true,
5219
+ });
4158
5220
  });
4159
- await this._sendControl(parentHash, encodeJoinReq(ch.id.key, reqId, ch.bidPerByte));
5221
+ await this._sendControl(parentHash, this.codec.encodeJoinReq(ch.id.key, reqId, ch.bidPerByte, options?.parentUpgradeReservationToken));
4160
5222
  const res = await Promise.race([
4161
5223
  p,
4162
5224
  delay(Math.max(1, timeoutMs), { signal }).then(() => ({
@@ -4168,13 +5230,41 @@ export class FanoutTree extends DirectStream {
4168
5230
  ch.pendingJoin.delete(reqId);
4169
5231
  return res;
4170
5232
  }
4171
- async kickChildren(ch) {
4172
- const children = [...ch.children.keys()];
4173
- ch.children.clear();
4174
- ch.dataWriteFailStreakByChild.clear();
4175
- if (children.length === 0)
5233
+ async kickChildHashes(ch, children, options) {
5234
+ const unique = [...new Set(children)].filter((h) => ch.children.has(h));
5235
+ for (const h of unique) {
5236
+ ch.children.delete(h);
5237
+ ch.dataWriteFailStreakByChild.delete(h);
5238
+ }
5239
+ if (unique.length === 0)
4176
5240
  return;
4177
- await this._sendControlMany(children, encodeKick(ch.id.key));
5241
+ const resetPeerConnections = options?.resetPeerConnections === true;
5242
+ let kickFailed = false;
5243
+ try {
5244
+ const kickDelivered = await this._sendControlMany(unique, this.codec.encodeKick(ch.id.key));
5245
+ kickFailed = !kickDelivered;
5246
+ }
5247
+ catch (error) {
5248
+ kickFailed = true;
5249
+ throw error;
5250
+ }
5251
+ finally {
5252
+ if (resetPeerConnections && kickFailed) {
5253
+ // If KICK cannot be written on the existing stream, close the peer
5254
+ // connection so the topology change becomes observable.
5255
+ for (const h of unique) {
5256
+ const stream = this.peers.get(h);
5257
+ if (!stream)
5258
+ continue;
5259
+ void this.components.connectionManager
5260
+ .closeConnections(stream.peerId)
5261
+ .catch(() => { });
5262
+ }
5263
+ }
5264
+ }
5265
+ }
5266
+ async kickChildren(ch) {
5267
+ await this.kickChildHashes(ch, [...ch.children.keys()]);
4178
5268
  }
4179
5269
  async onDataMessage(from, peerStream, message, seenBefore) {
4180
5270
  const ignore = this.shouldIgnore(message, seenBefore);
@@ -4203,6 +5293,8 @@ export class FanoutTree extends DirectStream {
4203
5293
  kind === MSG_TRACKER_QUERY ||
4204
5294
  kind === MSG_TRACKER_REPLY ||
4205
5295
  kind === MSG_TRACKER_FEEDBACK ||
5296
+ kind === MSG_PARENT_PROBE_REQ ||
5297
+ kind === MSG_PARENT_PROBE_REPLY ||
4206
5298
  kind === MSG_PROVIDER_ANNOUNCE ||
4207
5299
  kind === MSG_PROVIDER_QUERY ||
4208
5300
  kind === MSG_PROVIDER_REPLY ||
@@ -4224,33 +5316,26 @@ export class FanoutTree extends DirectStream {
4224
5316
  fromHash === ch.parent)) {
4225
5317
  return false;
4226
5318
  }
5319
+ if (!ch && kind === MSG_JOIN_REQ && data.length >= 1 + 32 + 4) {
5320
+ // A joiner probed us for a channel we do not host (connected-peer
5321
+ // fallbacks routinely hit bootstraps and non-subscribers). Failing
5322
+ // fast lets the joiner spend its probe budget elsewhere instead of
5323
+ // burning a full join-request timeout and a long cooldown on us.
5324
+ const reqId = readU32BE(data, 33);
5325
+ void this._sendControl(fromHash, this.codec.encodeJoinReject(channelKey, reqId, JOIN_REJECT_NOT_ATTACHED)).catch(() => { });
5326
+ return true;
5327
+ }
4227
5328
  if (kind === MSG_TRACKER_ANNOUNCE) {
4228
- if (data.length < 1 + 32 + 4 + 2 + 2 + 2 + 4 + 1)
5329
+ const decoded = this.codec.decodeTrackerAnnounce(data);
5330
+ if (!decoded)
4229
5331
  return false;
4230
- const ttlMs = readU32BE(data, 33);
4231
- const level = readU16BE(data, 37);
4232
- // maxChildren is currently unused, but kept in the wire format.
4233
- // const maxChildren = readU16BE(data, 39);
4234
- const freeSlots = readU16BE(data, 41);
4235
- const bidPerByte = readU32BE(data, 43);
4236
- const addrCount = data[47];
4237
- let offset = 48;
4238
- const addrs = [];
4239
- const max = Math.min(addrCount, 16);
4240
- for (let i = 0; i < max; i++) {
4241
- if (offset + 2 > data.length)
4242
- break;
4243
- const len = readU16BE(data, offset);
4244
- offset += 2;
4245
- if (offset + len > data.length)
4246
- break;
4247
- addrs.push(data.subarray(offset, offset + len));
4248
- offset += len;
4249
- }
5332
+ const { ttlMs, level, freeSlots, bidPerByte, addrs } = decoded;
4250
5333
  if (addrs.length === 0) {
4251
5334
  try {
4252
5335
  const peer = await this.components.peerStore.get(peerStream.peerId);
4253
- const addresses = Array.isArray(peer?.addresses) ? peer.addresses : [];
5336
+ const addresses = Array.isArray(peer?.addresses)
5337
+ ? peer.addresses
5338
+ : [];
4254
5339
  for (const a of addresses) {
4255
5340
  const ma = a?.multiaddr ?? a;
4256
5341
  const bytes = normalizeUint8Array(ma?.bytes);
@@ -4296,10 +5381,10 @@ export class FanoutTree extends DirectStream {
4296
5381
  return true;
4297
5382
  }
4298
5383
  if (kind === MSG_TRACKER_QUERY) {
4299
- if (data.length < 1 + 32 + 4 + 2)
5384
+ const decoded = this.codec.decodeTrackerQuery(data);
5385
+ if (!decoded)
4300
5386
  return false;
4301
- const reqId = readU32BE(data, 33);
4302
- const want = readU16BE(data, 37);
5387
+ const { reqId, want } = decoded;
4303
5388
  const now = Date.now();
4304
5389
  this.touchTrackerNamespace(suffixKey, now);
4305
5390
  const byPeer = this.trackerBySuffixKey.get(suffixKey);
@@ -4312,8 +5397,9 @@ export class FanoutTree extends DirectStream {
4312
5397
  }
4313
5398
  if (hash === fromHash)
4314
5399
  continue;
4315
- if (e.freeSlots <= 0)
5400
+ if (e.freeSlots <= 0) {
4316
5401
  continue;
5402
+ }
4317
5403
  entries.push(e);
4318
5404
  }
4319
5405
  this.pruneTrackerNamespaceIfEmpty(suffixKey);
@@ -4327,21 +5413,28 @@ export class FanoutTree extends DirectStream {
4327
5413
  return b.bidPerByte - a.bidPerByte;
4328
5414
  return a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0;
4329
5415
  });
4330
- const picked = entries.slice(0, clampU16(want));
4331
- void this._sendControl(fromHash, encodeTrackerReply(channelKey, reqId, picked));
5416
+ // Returning the deterministic head of the directory sends every
5417
+ // concurrent joiner to the same few candidates while deeper capacity
5418
+ // goes unprobed. Sample the reply from a wider best-first pool so
5419
+ // load spreads across all advertised capacity.
5420
+ const wantCount = clampU16(want);
5421
+ const poolSize = Math.min(entries.length, Math.max(wantCount * 4, wantCount));
5422
+ const pool = entries.slice(0, poolSize);
5423
+ for (let i = 0; i < Math.min(wantCount, pool.length); i++) {
5424
+ const j = i + Math.floor(this.random() * (pool.length - i));
5425
+ const tmp = pool[i];
5426
+ pool[i] = pool[j];
5427
+ pool[j] = tmp;
5428
+ }
5429
+ const picked = pool.slice(0, wantCount);
5430
+ void this._sendControl(fromHash, this.codec.encodeTrackerReply(channelKey, reqId, picked));
4332
5431
  return true;
4333
5432
  }
4334
5433
  if (kind === MSG_TRACKER_FEEDBACK) {
4335
- if (data.length < 1 + 32 + 1 + 1 + 1)
4336
- return false;
4337
- const hashLen = data[33];
4338
- let offset = 34;
4339
- if (offset + hashLen + 2 > data.length)
5434
+ const decoded = this.codec.decodeTrackerFeedback(data);
5435
+ if (!decoded)
4340
5436
  return false;
4341
- const candidateHash = textDecoder.decode(data.subarray(offset, offset + hashLen));
4342
- offset += hashLen;
4343
- const event = data[offset++] & 0xff;
4344
- const reason = data[offset++] & 0xff;
5437
+ const { candidateHash, event, reason } = decoded;
4345
5438
  const byPeer = this.trackerBySuffixKey.get(suffixKey);
4346
5439
  if (!byPeer)
4347
5440
  return true;
@@ -4359,13 +5452,15 @@ export class FanoutTree extends DirectStream {
4359
5452
  entry.freeSlots = clampU16(Math.max(0, entry.freeSlots - 1));
4360
5453
  return true;
4361
5454
  }
4362
- if (event === TRACKER_FEEDBACK_DIAL_FAILED || event === TRACKER_FEEDBACK_JOIN_TIMEOUT) {
5455
+ if (event === TRACKER_FEEDBACK_DIAL_FAILED ||
5456
+ event === TRACKER_FEEDBACK_JOIN_TIMEOUT) {
4363
5457
  byPeer.delete(candidateHash);
4364
5458
  this.pruneTrackerNamespaceIfEmpty(suffixKey);
4365
5459
  return true;
4366
5460
  }
4367
5461
  if (event === TRACKER_FEEDBACK_JOIN_REJECT) {
4368
- if (reason === JOIN_REJECT_NO_CAPACITY || reason === JOIN_REJECT_NOT_ATTACHED) {
5462
+ if (reason === JOIN_REJECT_NO_CAPACITY ||
5463
+ reason === JOIN_REJECT_NOT_ATTACHED) {
4369
5464
  entry.freeSlots = 0;
4370
5465
  // Expire earlier to reduce time-to-recovery if the entry is stale.
4371
5466
  entry.expiresAt = Math.min(entry.expiresAt, now + 2_000);
@@ -4375,24 +5470,11 @@ export class FanoutTree extends DirectStream {
4375
5470
  return true;
4376
5471
  }
4377
5472
  if (kind === MSG_PROVIDER_ANNOUNCE) {
4378
- if (data.length < 1 + 32 + 4 + 1)
5473
+ const decoded = this.codec.decodeProviderAnnounce(data);
5474
+ if (!decoded)
4379
5475
  return false;
4380
5476
  const providerId = this.getProviderNamespaceIdFromKey(channelKey, suffixKey);
4381
- const ttlMs = readU32BE(data, 33);
4382
- const addrCount = data[37];
4383
- let offset = 38;
4384
- const addrs = [];
4385
- const max = Math.min(addrCount, 16);
4386
- for (let i = 0; i < max; i++) {
4387
- if (offset + 2 > data.length)
4388
- break;
4389
- const len = readU16BE(data, offset);
4390
- offset += 2;
4391
- if (offset + len > data.length)
4392
- break;
4393
- addrs.push(data.subarray(offset, offset + len));
4394
- offset += len;
4395
- }
5477
+ const { ttlMs, addrs } = decoded;
4396
5478
  if (addrs.length === 0) {
4397
5479
  try {
4398
5480
  const peer = await this.components.peerStore.get(peerStream.peerId);
@@ -4446,11 +5528,10 @@ export class FanoutTree extends DirectStream {
4446
5528
  return true;
4447
5529
  }
4448
5530
  if (kind === MSG_PROVIDER_QUERY) {
4449
- if (data.length < 1 + 32 + 4 + 2 + 4)
5531
+ const decoded = this.codec.decodeProviderQuery(data);
5532
+ if (!decoded)
4450
5533
  return false;
4451
- const reqId = readU32BE(data, 33);
4452
- const want = readU16BE(data, 37);
4453
- const seed = readU32BE(data, 39);
5534
+ const { reqId, want, seed } = decoded;
4454
5535
  const now = Date.now();
4455
5536
  this.touchProviderNamespace(suffixKey, now);
4456
5537
  const byPeer = this.providerBySuffixKey.get(suffixKey);
@@ -4491,14 +5572,15 @@ export class FanoutTree extends DirectStream {
4491
5572
  }
4492
5573
  }
4493
5574
  const picked = entries.slice(0, clampU16(want));
4494
- void this._sendControl(fromHash, encodeProviderReply(channelKey, reqId, picked));
5575
+ void this._sendControl(fromHash, this.codec.encodeProviderReply(channelKey, reqId, picked));
4495
5576
  return true;
4496
5577
  }
4497
5578
  if (kind === MSG_PROVIDER_SUBSCRIBE) {
4498
- if (data.length < 1 + 32 + 2 + 4)
5579
+ const decoded = this.codec.decodeProviderSubscribe(data);
5580
+ if (!decoded)
4499
5581
  return false;
4500
- const want = Math.max(1, readU16BE(data, 33));
4501
- const ttlMs = Math.min(120_000, Math.max(1_000, readU32BE(data, 35)));
5582
+ const want = Math.max(1, decoded.want);
5583
+ const ttlMs = Math.min(120_000, Math.max(1_000, decoded.ttlMs));
4502
5584
  const now = Date.now();
4503
5585
  let watchers = this.providerWatchersBySuffixKey.get(suffixKey);
4504
5586
  if (!watchers) {
@@ -4520,18 +5602,18 @@ export class FanoutTree extends DirectStream {
4520
5602
  return true;
4521
5603
  }
4522
5604
  if (kind === MSG_PROVIDER_REPLY) {
4523
- if (data.length < 1 + 32 + 4 + 1)
5605
+ const decoded = this.codec.decodeProviderReply(data);
5606
+ if (!decoded)
4524
5607
  return false;
4525
- const reqId = readU32BE(data, 33);
5608
+ const reqId = decoded.reqId;
4526
5609
  const pendingByReq = this.pendingProviderQueryBySuffixKey.get(suffixKey);
4527
5610
  const pending = pendingByReq?.get(reqId);
4528
5611
  if (!pending)
4529
5612
  return true;
4530
5613
  pendingByReq.delete(reqId);
4531
- const count = data[37];
4532
5614
  const now = Date.now();
4533
5615
  this.touchProviderNamespace(suffixKey, now);
4534
- const { providers: candidates } = decodeProviderEntries(data, 38, count);
5616
+ const candidates = this.toProviderCandidates(decoded.entries);
4535
5617
  this.rememberProviderCandidates(this.getProviderNamespaceIdFromKey(channelKey, suffixKey), candidates, now + 60_000);
4536
5618
  this.pruneProviderNamespaceIfEmpty(suffixKey);
4537
5619
  if (candidates.length > 0) {
@@ -4541,11 +5623,11 @@ export class FanoutTree extends DirectStream {
4541
5623
  return true;
4542
5624
  }
4543
5625
  if (kind === MSG_PROVIDER_NOTIFY) {
4544
- if (data.length < 1 + 32 + 1)
5626
+ const decoded = this.codec.decodeProviderNotify(data);
5627
+ if (!decoded)
4545
5628
  return false;
4546
- const count = data[33];
4547
5629
  const now = Date.now();
4548
- const { providers } = decodeProviderEntries(data, 34, count);
5630
+ const providers = this.toProviderCandidates(decoded.entries);
4549
5631
  this.rememberProviderCandidates(this.getProviderNamespaceIdFromKey(channelKey, suffixKey), providers, now + 60_000);
4550
5632
  if (providers.length > 0) {
4551
5633
  this.emitProviderUpdate(this.getProviderNamespaceIdFromKey(channelKey, suffixKey), providers);
@@ -4555,45 +5637,18 @@ export class FanoutTree extends DirectStream {
4555
5637
  if (kind === MSG_TRACKER_REPLY) {
4556
5638
  if (!ch)
4557
5639
  return true;
4558
- if (data.length < 1 + 32 + 4 + 1)
5640
+ const decoded = this.codec.decodeTrackerReply(data);
5641
+ if (!decoded)
4559
5642
  return false;
4560
- const reqId = readU32BE(data, 33);
5643
+ const reqId = decoded.reqId;
4561
5644
  const pending = ch.pendingTrackerQuery.get(reqId);
4562
- if (!pending)
4563
- return true;
4564
- ch.pendingTrackerQuery.delete(reqId);
4565
- const count = data[37];
4566
- let offset = 38;
5645
+ if (pending) {
5646
+ ch.pendingTrackerQuery.delete(reqId);
5647
+ }
4567
5648
  const candidates = [];
4568
- const max = Math.min(count, 255);
4569
- for (let i = 0; i < max; i++) {
4570
- if (offset + 1 > data.length)
4571
- break;
4572
- const hashLen = data[offset++];
4573
- if (offset + hashLen > data.length)
4574
- break;
4575
- const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
4576
- offset += hashLen;
4577
- if (offset + 2 + 2 + 4 + 1 > data.length)
4578
- break;
4579
- const level = readU16BE(data, offset);
4580
- offset += 2;
4581
- const freeSlots = readU16BE(data, offset);
4582
- offset += 2;
4583
- const bidPerByte = readU32BE(data, offset);
4584
- offset += 4;
4585
- const addrCount = data[offset++];
5649
+ for (const entry of decoded.entries) {
4586
5650
  const addrs = [];
4587
- const addrMax = Math.min(addrCount, 16);
4588
- for (let j = 0; j < addrMax; j++) {
4589
- if (offset + 2 > data.length)
4590
- break;
4591
- const len = readU16BE(data, offset);
4592
- offset += 2;
4593
- if (offset + len > data.length)
4594
- break;
4595
- const bytes = data.subarray(offset, offset + len);
4596
- offset += len;
5651
+ for (const bytes of entry.addrs) {
4597
5652
  try {
4598
5653
  addrs.push(multiaddr(bytes));
4599
5654
  }
@@ -4601,10 +5656,56 @@ export class FanoutTree extends DirectStream {
4601
5656
  // ignore invalid multiaddrs
4602
5657
  }
4603
5658
  }
4604
- candidates.push({ hash, level, freeSlots, bidPerByte, addrs });
4605
- this.cacheKnownCandidateAddrs(ch, hash, addrs);
5659
+ candidates.push({
5660
+ hash: entry.hash,
5661
+ level: entry.level,
5662
+ freeSlots: entry.freeSlots,
5663
+ bidPerByte: entry.bidPerByte,
5664
+ addrs,
5665
+ });
5666
+ this.cacheKnownCandidateAddrs(ch, entry.hash, addrs);
4606
5667
  }
4607
- pending.resolve(candidates);
5668
+ if (pending) {
5669
+ pending.resolve(candidates);
5670
+ }
5671
+ else if (candidates.length > 0) {
5672
+ // Late reply: the query already timed out, but the candidate
5673
+ // list is still fresh directory state. Under load the 1s query
5674
+ // timeout is routinely missed while trackers hold plenty of
5675
+ // capacity - dropping these replies starves joiners of
5676
+ // candidates they already paid a round trip for.
5677
+ const merged = new Map();
5678
+ for (const c of ch.cachedTrackerCandidates)
5679
+ merged.set(c.hash, c);
5680
+ for (const c of candidates)
5681
+ merged.set(c.hash, c);
5682
+ ch.cachedTrackerCandidates = [...merged.values()].slice(-256);
5683
+ }
5684
+ return true;
5685
+ }
5686
+ if (kind === MSG_PARENT_PROBE_REQ) {
5687
+ if (!ch || ch.closed)
5688
+ return true;
5689
+ const decoded = this.codec.decodeParentProbeReq(data);
5690
+ if (!decoded)
5691
+ return false;
5692
+ this.touchPeerHint(ch, fromHash);
5693
+ void this._sendControl(fromHash, this.encodeParentProbeReplyForChannel(ch, decoded.reqId, fromHash, decoded.minFreeSlots, decoded.reserveRootCapacity)).catch(() => { });
5694
+ return true;
5695
+ }
5696
+ if (kind === MSG_PARENT_PROBE_REPLY) {
5697
+ if (!ch || ch.closed)
5698
+ return true;
5699
+ const decoded = this.codec.decodeParentProbeReply(data, fromHash);
5700
+ if (!decoded)
5701
+ return false;
5702
+ const { reqId, ...reply } = decoded;
5703
+ const pending = ch.pendingParentProbe.get(reqId);
5704
+ if (!pending)
5705
+ return true;
5706
+ ch.pendingParentProbe.delete(reqId);
5707
+ pending.resolve(reply);
5708
+ this.touchPeerHint(ch, fromHash);
4608
5709
  return true;
4609
5710
  }
4610
5711
  if (!ch)
@@ -4623,20 +5724,20 @@ export class FanoutTree extends DirectStream {
4623
5724
  return true;
4624
5725
  }
4625
5726
  if (kind === MSG_ROUTE_QUERY) {
4626
- if (data.length < 1 + 32 + 4 + 1)
5727
+ const decoded = this.codec.decodeRouteQuery(data);
5728
+ if (!decoded)
4627
5729
  return false;
4628
- const reqId = readU32BE(data, 33);
4629
- const hashLen = data[37];
4630
- if (hashLen === 0 || 38 + hashLen > data.length) {
4631
- void this._sendControl(fromHash, encodeRouteReply(ch.id.key, reqId)).catch(() => { });
5730
+ const reqId = decoded.reqId;
5731
+ if (decoded.targetHash == null) {
5732
+ void this._sendControl(fromHash, this.codec.encodeRouteReply(ch.id.key, reqId)).catch(() => { });
4632
5733
  return true;
4633
5734
  }
4634
- const targetHash = textDecoder.decode(data.subarray(38, 38 + hashLen));
5735
+ const targetHash = decoded.targetHash;
4635
5736
  const localRoute = targetHash === this.publicKeyHash && ch.routeFromRoot
4636
5737
  ? ch.routeFromRoot
4637
5738
  : this.getCachedRoute(ch, targetHash);
4638
5739
  if (this.isRouteValidForChannel(ch, localRoute)) {
4639
- void this._sendControl(fromHash, encodeRouteReply(ch.id.key, reqId, localRoute)).catch(() => { });
5740
+ void this._sendControl(fromHash, this.codec.encodeRouteReply(ch.id.key, reqId, localRoute)).catch(() => { });
4640
5741
  return true;
4641
5742
  }
4642
5743
  if (ch.isRoot) {
@@ -4646,7 +5747,7 @@ export class FanoutTree extends DirectStream {
4646
5747
  if (rootRoute)
4647
5748
  this.cacheRoute(ch, rootRoute);
4648
5749
  if (rootRoute) {
4649
- void this._sendControl(fromHash, encodeRouteReply(ch.id.key, reqId, rootRoute)).catch(() => { });
5750
+ void this._sendControl(fromHash, this.codec.encodeRouteReply(ch.id.key, reqId, rootRoute)).catch(() => { });
4650
5751
  return true;
4651
5752
  }
4652
5753
  }
@@ -4659,16 +5760,19 @@ export class FanoutTree extends DirectStream {
4659
5760
  }
4660
5761
  // Cache miss on a parent->child (or root->child) query:
4661
5762
  // recursively search subtree branches and reply with the first valid route.
4662
- this.proxyRouteQuery(ch, fromHash, reqId, targetHash, [...ch.children.keys()]);
5763
+ this.proxyRouteQuery(ch, fromHash, reqId, targetHash, [
5764
+ ...ch.children.keys(),
5765
+ ]);
4663
5766
  return true;
4664
5767
  }
4665
5768
  if (kind === MSG_ROUTE_REPLY) {
4666
- if (data.length < 1 + 32 + 4 + 1)
5769
+ const decoded = this.codec.decodeRouteReply(data);
5770
+ if (!decoded)
4667
5771
  return false;
4668
- const reqId = readU32BE(data, 33);
4669
- const routeCount = Math.min(255, data[37]);
4670
- const { route } = decodeRoute(data, 38, routeCount);
4671
- const parsedRoute = this.isRouteValidForChannel(ch, route) ? route : undefined;
5772
+ const { reqId, route } = decoded;
5773
+ const parsedRoute = this.isRouteValidForChannel(ch, route)
5774
+ ? route
5775
+ : undefined;
4672
5776
  if (parsedRoute) {
4673
5777
  this.cacheRoute(ch, parsedRoute);
4674
5778
  }
@@ -4694,11 +5798,16 @@ export class FanoutTree extends DirectStream {
4694
5798
  return true;
4695
5799
  }
4696
5800
  if (kind === MSG_IHAVE) {
4697
- if (data.length < 1 + 32 + 4 + 4)
5801
+ const decoded = this.codec.decodeIHave(data);
5802
+ if (!decoded)
4698
5803
  return false;
4699
- const haveFrom = readU32BE(data, 33);
4700
- const haveToExclusive = readU32BE(data, 37);
5804
+ const { haveFrom, haveToExclusive } = decoded;
4701
5805
  const now = Date.now();
5806
+ if (ch.parent && fromHash === ch.parent) {
5807
+ ch.lastParentDataAt = now;
5808
+ ch.receivedAnyParentData = true;
5809
+ ch.parentRepairUnansweredStreak = 0;
5810
+ }
4702
5811
  const prev = ch.haveByPeer.get(fromHash);
4703
5812
  if (prev) {
4704
5813
  prev.haveFrom = haveFrom;
@@ -4715,6 +5824,16 @@ export class FanoutTree extends DirectStream {
4715
5824
  });
4716
5825
  }
4717
5826
  this.touchPeerHint(ch, fromHash, now);
5827
+ if (ch.parent && fromHash === ch.parent && ch.maxDataAgeMs === 0) {
5828
+ // A watermark from our parent doubles as gap detection: if data
5829
+ // writes to us were lost (e.g. burst into a not-yet-writable
5830
+ // stream) we may have received nothing at all and would
5831
+ // otherwise never know data exists. Mark the advertised range
5832
+ // missing so the repair loop pulls it. Deadline-oriented live
5833
+ // channels (maxDataAgeMs > 0) skip this: catching up on stale
5834
+ // history would trade deadline latency for completeness.
5835
+ this.noteEnd(ch, fromHash, haveToExclusive);
5836
+ }
4718
5837
  // Opportunistic reciprocity: if we still have room in our lazy mesh, add
4719
5838
  // peers that are actively exchanging IHAVE summaries with us.
4720
5839
  if (ch.neighborRepair &&
@@ -4728,11 +5847,21 @@ export class FanoutTree extends DirectStream {
4728
5847
  return true;
4729
5848
  }
4730
5849
  if (kind === MSG_JOIN_REQ) {
4731
- if (data.length < 1 + 32 + 4 + 4)
5850
+ const decoded = this.codec.decodeJoinReq(data);
5851
+ if (!decoded)
4732
5852
  return false;
4733
- const reqId = readU32BE(data, 33);
4734
- const bidPerByte = readU32BE(data, 37);
5853
+ const { reqId, bidPerByte, parentUpgradeReservationToken } = decoded;
4735
5854
  this.pruneDisconnectedChildren(ch);
5855
+ const hasParentUpgradeReservation = ch.isRoot && parentUpgradeReservationToken > 0;
5856
+ const consumedParentUpgradeReservation = hasParentUpgradeReservation
5857
+ ? this.consumeParentUpgradeReservation(ch, fromHash, parentUpgradeReservationToken)
5858
+ : false;
5859
+ if (hasParentUpgradeReservation && !consumedParentUpgradeReservation) {
5860
+ ch.metrics.parentUpgradeRootReservationRejected += 1;
5861
+ void this.sendJoinReject(ch, fromHash, reqId, JOIN_REJECT_NO_CAPACITY).catch(() => { });
5862
+ void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
5863
+ return true;
5864
+ }
4736
5865
  // Only accept if we're already attached.
4737
5866
  if (!ch.isRoot && !ch.parent) {
4738
5867
  void this.sendJoinReject(ch, fromHash, reqId, JOIN_REJECT_NOT_ATTACHED).catch(() => { });
@@ -4755,8 +5884,16 @@ export class FanoutTree extends DirectStream {
4755
5884
  void this.sendJoinReject(ch, fromHash, reqId, JOIN_REJECT_NO_CAPACITY).catch(() => { });
4756
5885
  return true;
4757
5886
  }
4758
- if (!ch.children.has(fromHash) && ch.children.size >= ch.effectiveMaxChildren) {
4759
- if (!ch.allowKick) {
5887
+ if (!ch.children.has(fromHash) &&
5888
+ (consumedParentUpgradeReservation
5889
+ ? ch.children.size >= ch.effectiveMaxChildren
5890
+ : ch.children.size +
5891
+ this.parentUpgradeReservationCount(ch, fromHash) >=
5892
+ ch.effectiveMaxChildren)) {
5893
+ const blockedByReservedRootCapacity = ch.isRoot &&
5894
+ !consumedParentUpgradeReservation &&
5895
+ ch.children.size < ch.effectiveMaxChildren;
5896
+ if (!ch.allowKick || blockedByReservedRootCapacity) {
4760
5897
  void this.sendJoinReject(ch, fromHash, reqId, JOIN_REJECT_NO_CAPACITY).catch(() => { });
4761
5898
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
4762
5899
  return true;
@@ -4774,56 +5911,72 @@ export class FanoutTree extends DirectStream {
4774
5911
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
4775
5912
  return true;
4776
5913
  }
4777
- ch.children.delete(worstChild);
4778
- ch.dataWriteFailStreakByChild.delete(worstChild);
4779
- void this._sendControl(worstChild, encodeKick(ch.id.key));
5914
+ void this.kickChildHashes(ch, [worstChild]).catch(() => { });
4780
5915
  }
4781
5916
  ch.children.set(fromHash, { bidPerByte });
5917
+ if (ch.isRoot && consumedParentUpgradeReservation) {
5918
+ ch.parentUpgradeTrackerNoCapacityUntil = Math.max(ch.parentUpgradeTrackerNoCapacityUntil, Date.now() + 2_000);
5919
+ }
4782
5920
  this.touchPeerHint(ch, fromHash);
4783
- void this._sendControl(fromHash, encodeJoinAccept(ch.id.key, reqId, ch.level, ch.routeFromRoot));
5921
+ const joinAccept = this.codec.encodeJoinAccept(ch.id.key, reqId, ch.level, ch.routeFromRoot, this.getHaveRange(ch));
5922
+ void (async () => {
5923
+ await this._sendControl(fromHash, joinAccept);
5924
+ if (ch.endSeqExclusive > 0) {
5925
+ await this._sendControl(fromHash, this.codec.encodeEnd(ch.id.key, ch.endSeqExclusive));
5926
+ }
5927
+ })().catch(() => { });
4784
5928
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
4785
5929
  return true;
4786
5930
  }
4787
5931
  if (kind === MSG_JOIN_ACCEPT || kind === MSG_JOIN_REJECT) {
4788
- if (data.length < 1 + 32 + 4)
5932
+ const reqId = this.codec.decodeJoinResponseReqId(data);
5933
+ if (reqId == null)
4789
5934
  return false;
4790
- const reqId = readU32BE(data, 33);
5935
+ // Any explicit join response proves this path is alive end to end.
5936
+ this.noteJoinResponse(fromHash);
4791
5937
  const pending = ch.pendingJoin.get(reqId);
4792
5938
  if (!pending)
4793
5939
  return true;
4794
5940
  ch.pendingJoin.delete(reqId);
4795
5941
  if (kind === MSG_JOIN_ACCEPT) {
4796
- if (data.length < 1 + 32 + 4 + 2 + 1)
5942
+ const decoded = this.codec.decodeJoinAccept(data);
5943
+ if (!decoded)
4797
5944
  return false;
4798
- const parentLevel = readU16BE(data, 37);
4799
- const routeCount = Math.min(255, data[39]);
4800
- let offset = 40;
4801
- const parentRouteFromRoot = [];
4802
- const max = Math.min(routeCount, MAX_ROUTE_HOPS);
4803
- for (let i = 0; i < max; i++) {
4804
- if (offset + 1 > data.length)
4805
- break;
4806
- const len = data[offset++];
4807
- if (len === 0)
4808
- break;
4809
- if (offset + len > data.length)
4810
- break;
4811
- parentRouteFromRoot.push(textDecoder.decode(data.subarray(offset, offset + len)));
4812
- offset += len;
4813
- }
5945
+ const { parentLevel, parentRouteFromRoot } = decoded;
4814
5946
  const hasValidParentRoute = parentRouteFromRoot.length > 0 &&
4815
5947
  parentRouteFromRoot[0] === ch.id.root &&
4816
5948
  parentRouteFromRoot[parentRouteFromRoot.length - 1] === fromHash;
4817
5949
  // Defensive: a JOIN_ACCEPT without a rooted route token (unless the parent is the
4818
5950
  // actual root) can create stable disconnected components. Treat it as a reject.
4819
5951
  if (fromHash !== ch.id.root && !hasValidParentRoute) {
4820
- pending.resolve({ ok: false, rejectReason: JOIN_REJECT_NOT_ATTACHED });
5952
+ pending.resolve({
5953
+ ok: false,
5954
+ rejectReason: JOIN_REJECT_NOT_ATTACHED,
5955
+ });
5956
+ return true;
5957
+ }
5958
+ const acceptedParentRoute = hasValidParentRoute || fromHash === ch.id.root
5959
+ ? fromHash === ch.id.root && !hasValidParentRoute
5960
+ ? [ch.id.root]
5961
+ : parentRouteFromRoot
5962
+ : undefined;
5963
+ if (pending.shadowAttach) {
5964
+ this.touchPeerHint(ch, fromHash);
5965
+ pending.resolve({
5966
+ ok: true,
5967
+ parentLevel,
5968
+ parentRouteFromRoot: acceptedParentRoute,
5969
+ });
4821
5970
  return true;
4822
5971
  }
5972
+ const attachedAt = Date.now();
4823
5973
  ch.parent = fromHash;
4824
5974
  ch.level = parentLevel + 1;
4825
5975
  ch.joinedAtLeastOnce = true;
4826
- ch.lastParentDataAt = Date.now();
5976
+ ch.lastParentDataAt = attachedAt;
5977
+ ch.lastParentUpgradeActivityAt = attachedAt;
5978
+ ch.parentRepairUnansweredStreak = 0;
5979
+ this.resetParentDataLatency(ch);
4827
5980
  // Treat JOIN_ACCEPT as parent liveness for stale re-parenting:
4828
5981
  // if callers enable `staleAfterMs`, we should be able to detach even
4829
5982
  // before the first data message arrives (for example, during churn
@@ -4837,58 +5990,56 @@ export class FanoutTree extends DirectStream {
4837
5990
  // Minimal fallback: parent is the root.
4838
5991
  ch.routeFromRoot = [ch.id.root, this.publicKeyHash];
4839
5992
  }
5993
+ if (ch.repairEnabled &&
5994
+ ch.maxDataAgeMs === 0 &&
5995
+ decoded.haveRange != null) {
5996
+ // Optional appendix: the parent's current have-range. Lets a
5997
+ // freshly attached child learn immediately which sequences
5998
+ // already exist, so lost deliveries surface as missing seqs
5999
+ // (and repair) instead of silent absence.
6000
+ const { haveFrom, haveToExclusive } = decoded.haveRange;
6001
+ if (haveToExclusive > haveFrom) {
6002
+ // Mark only; the repair loop's own cadence picks the gaps
6003
+ // up, giving in-flight live deliveries time to land first
6004
+ // (an immediate pull here races them into duplicates).
6005
+ this.noteEnd(ch, fromHash, haveToExclusive);
6006
+ }
6007
+ }
4840
6008
  this.touchPeerHint(ch, fromHash);
4841
- pending.resolve({ ok: true });
6009
+ pending.resolve({
6010
+ ok: true,
6011
+ parentLevel,
6012
+ parentRouteFromRoot: acceptedParentRoute,
6013
+ });
4842
6014
  this.dispatchEvent(new CustomEvent("fanout:joined", {
4843
- detail: { topic: ch.id.topic, root: ch.id.root, parent: fromHash },
6015
+ detail: {
6016
+ topic: ch.id.topic,
6017
+ root: ch.id.root,
6018
+ parent: fromHash,
6019
+ },
4844
6020
  }));
4845
6021
  void this.announceToTrackers(ch, this.closeController.signal).catch(() => { });
4846
6022
  }
4847
6023
  else {
4848
- if (data.length < 1 + 32 + 4 + 1)
6024
+ const decoded = this.codec.decodeJoinReject(data);
6025
+ if (!decoded)
4849
6026
  return false;
4850
- const reason = data[37] & 0xff;
6027
+ const reason = decoded.reason;
4851
6028
  const redirects = [];
4852
- if (data.length >= 1 + 32 + 4 + 1 + 1) {
4853
- const count = Math.min(255, data[38]);
4854
- let offset = 39;
4855
- const max = Math.min(count, JOIN_REJECT_REDIRECT_MAX);
4856
- for (let i = 0; i < max; i++) {
4857
- if (offset + 1 > data.length)
4858
- break;
4859
- const hashLen = data[offset++];
4860
- if (hashLen === 0)
4861
- break;
4862
- if (offset + hashLen > data.length)
4863
- break;
4864
- const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
4865
- offset += hashLen;
4866
- if (offset + 1 > data.length)
4867
- break;
4868
- const addrCount = Math.min(255, data[offset++]);
4869
- const addrs = [];
4870
- const addrMax = Math.min(addrCount, JOIN_REJECT_REDIRECT_ADDR_MAX);
4871
- for (let j = 0; j < addrMax; j++) {
4872
- if (offset + 2 > data.length)
4873
- break;
4874
- const len = readU16BE(data, offset);
4875
- offset += 2;
4876
- if (offset + len > data.length)
4877
- break;
4878
- const bytes = data.subarray(offset, offset + len);
4879
- offset += len;
4880
- try {
4881
- addrs.push(multiaddr(bytes));
4882
- }
4883
- catch {
4884
- // ignore invalid multiaddrs
4885
- }
6029
+ for (const redirect of decoded.redirects) {
6030
+ const addrs = [];
6031
+ for (const bytes of redirect.addrs) {
6032
+ try {
6033
+ addrs.push(multiaddr(bytes));
4886
6034
  }
4887
- if (hash && addrs.length > 0) {
4888
- redirects.push({ hash, addrs });
4889
- this.cacheKnownCandidateAddrs(ch, hash, addrs);
6035
+ catch {
6036
+ // ignore invalid multiaddrs
4890
6037
  }
4891
6038
  }
6039
+ if (redirect.hash && addrs.length > 0) {
6040
+ redirects.push({ hash: redirect.hash, addrs });
6041
+ this.cacheKnownCandidateAddrs(ch, redirect.hash, addrs);
6042
+ }
4892
6043
  }
4893
6044
  pending.resolve({ ok: false, rejectReason: reason, redirects });
4894
6045
  }
@@ -4954,11 +6105,10 @@ export class FanoutTree extends DirectStream {
4954
6105
  const isFromChild = ch.children.has(fromHash);
4955
6106
  if (!isFromParent && !isFromChild)
4956
6107
  return true;
4957
- if (data.length < 1 + 32 + 8 + 1)
6108
+ const decoded = this.codec.decodeUnicastAck(data);
6109
+ if (!decoded)
4958
6110
  return false;
4959
- const ackToken = readU64BE(data, 33);
4960
- const routeCount = Math.min(255, data[41]);
4961
- const decoded = decodeRoute(data, 42, routeCount);
6111
+ const ackToken = decoded.ackToken;
4962
6112
  const route = decoded.route;
4963
6113
  const target = route.length > 0 ? route[route.length - 1] : "";
4964
6114
  const origin = message.header.signatures?.publicKeys?.[0]?.hashcode?.() ?? fromHash;
@@ -5038,33 +6188,13 @@ export class FanoutTree extends DirectStream {
5038
6188
  return true;
5039
6189
  }
5040
6190
  }
5041
- if (data.length < 1 + 32 + 1 + 1)
6191
+ const decoded = this.codec.decodeUnicast(data);
6192
+ if (!decoded)
5042
6193
  return false;
5043
- const flags = data[33] & 0xff;
5044
- let offset = 34;
5045
- let ackToken;
5046
- if (flags & UNICAST_FLAG_ACK) {
5047
- if (data.length < offset + 8 + 1)
5048
- return false;
5049
- ackToken = readU64BE(data, offset);
5050
- offset += 8;
5051
- }
5052
- const routeCount = Math.min(255, data[offset]);
5053
- offset += 1;
5054
- const decoded = decodeRoute(data, offset, routeCount);
6194
+ const ackToken = decoded.ackToken;
5055
6195
  const route = decoded.route;
5056
- offset = decoded.offset;
5057
- let replyRoute;
5058
- if (ackToken != null) {
5059
- if (data.length < offset + 1)
5060
- return false;
5061
- const replyCount = Math.min(255, data[offset]);
5062
- offset += 1;
5063
- const decodedReply = decodeRoute(data, offset, replyCount);
5064
- replyRoute = decodedReply.route;
5065
- offset = decodedReply.offset;
5066
- }
5067
- const payload = data.subarray(offset);
6196
+ const replyRoute = decoded.replyRoute;
6197
+ const payload = data.subarray(decoded.payloadOffset);
5068
6198
  const target = route.length > 0 ? route[route.length - 1] : "";
5069
6199
  const origin = message.header.signatures?.publicKeys?.[0]?.hashcode?.() ?? fromHash;
5070
6200
  this.touchPeerHint(ch, fromHash);
@@ -5094,7 +6224,7 @@ export class FanoutTree extends DirectStream {
5094
6224
  if (canAck) {
5095
6225
  const nextHop = replyRoute[1];
5096
6226
  if (nextHop && ch.children.has(nextHop)) {
5097
- void this._sendControl(nextHop, encodeUnicastAck(ch.id.key, ackToken, replyRoute)).catch(() => { });
6227
+ void this._sendControl(nextHop, this.codec.encodeUnicastAck(ch.id.key, ackToken, replyRoute)).catch(() => { });
5098
6228
  }
5099
6229
  }
5100
6230
  }
@@ -5106,6 +6236,11 @@ export class FanoutTree extends DirectStream {
5106
6236
  const stream = this.peers.get(nextHop);
5107
6237
  if (!stream)
5108
6238
  return true;
6239
+ // A validated source route is passing through: learn it (and the
6240
+ // reply route) so subsequent unicasts skip route discovery.
6241
+ this.cacheRoute(ch, route);
6242
+ if (replyRoute)
6243
+ this.cacheRoute(ch, replyRoute);
5109
6244
  void stream
5110
6245
  .waitForWrite(message.bytes(), Number(message.header.priority ?? CONTROL_PRIORITY), this.closeController.signal)
5111
6246
  .catch(() => { });
@@ -5137,7 +6272,7 @@ export class FanoutTree extends DirectStream {
5137
6272
  replyRoute[0] === ch.id.root &&
5138
6273
  replyRoute[replyRoute.length - 1] === origin;
5139
6274
  if (canAck) {
5140
- void this._sendControl(ch.parent, encodeUnicastAck(ch.id.key, ackToken, replyRoute)).catch(() => { });
6275
+ void this._sendControl(ch.parent, this.codec.encodeUnicastAck(ch.id.key, ackToken, replyRoute)).catch(() => { });
5141
6276
  }
5142
6277
  }
5143
6278
  return true;
@@ -5148,6 +6283,9 @@ export class FanoutTree extends DirectStream {
5148
6283
  const stream = this.peers.get(nextHop);
5149
6284
  if (!stream)
5150
6285
  return true;
6286
+ this.cacheRoute(ch, route);
6287
+ if (replyRoute)
6288
+ this.cacheRoute(ch, replyRoute);
5151
6289
  void stream
5152
6290
  .waitForWrite(message.bytes(), Number(message.header.priority ?? CONTROL_PRIORITY), this.closeController.signal)
5153
6291
  .catch(() => { });
@@ -5171,14 +6309,18 @@ export class FanoutTree extends DirectStream {
5171
6309
  ch.routeFromRoot = undefined;
5172
6310
  ch.routeByPeer.clear();
5173
6311
  ch.lastParentDataAt = 0;
6312
+ ch.lastParentUpgradeActivityAt = 0;
5174
6313
  ch.receivedAnyParentData = false;
5175
6314
  ch.pendingJoin.clear();
6315
+ ch.pendingParentProbe.clear();
6316
+ ch.parentShadow = undefined;
6317
+ void Promise.all(this.clearParentUpgradeGrace(ch, true, true)).catch(() => { });
6318
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
6319
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
6320
+ }
5176
6321
  ch.pendingRouteQuery.clear();
5177
6322
  this.abortPendingUnicastAcks(ch, new AbortError("fanout channel kicked"));
5178
- for (const pending of ch.pendingRouteProxy.values()) {
5179
- clearTimeout(pending.timer);
5180
- }
5181
- ch.pendingRouteProxy.clear();
6323
+ this.clearRouteProxies(ch);
5182
6324
  void this.kickChildren(ch).catch(() => { });
5183
6325
  this.dispatchEvent(new CustomEvent("fanout:kicked", {
5184
6326
  detail: { topic: ch.id.topic, root: ch.id.root, from: fromHash },
@@ -5186,27 +6328,30 @@ export class FanoutTree extends DirectStream {
5186
6328
  return true;
5187
6329
  }
5188
6330
  if (kind === MSG_END) {
5189
- if (data.length < 1 + 32 + 4)
6331
+ const lastSeqExclusive = this.codec.decodeEnd(data);
6332
+ if (lastSeqExclusive == null)
5190
6333
  return false;
5191
- const lastSeqExclusive = readU32BE(data, 33);
6334
+ if (ch.parent && fromHash === ch.parent) {
6335
+ ch.lastParentDataAt = Date.now();
6336
+ ch.receivedAnyParentData = true;
6337
+ ch.parentRepairUnansweredStreak = 0;
6338
+ }
5192
6339
  ch.endSeqExclusive = Math.max(ch.endSeqExclusive, lastSeqExclusive);
5193
6340
  this.touchPeerHint(ch, fromHash);
5194
6341
  this.noteEnd(ch, fromHash, lastSeqExclusive);
5195
6342
  if (ch.children.size > 0) {
5196
- void this._sendControlMany([...ch.children.keys()], encodeEnd(ch.id.key, lastSeqExclusive));
6343
+ void this._sendControlMany([...ch.children.keys()], this.codec.encodeEnd(ch.id.key, lastSeqExclusive));
5197
6344
  }
5198
6345
  void this.tickRepair(ch).catch(() => { });
5199
6346
  return true;
5200
6347
  }
5201
6348
  if (kind === MSG_REPAIR_REQ) {
5202
- if (data.length < 1 + 32 + 4 + 1)
6349
+ const seqs = this.codec.decodeRepairSeqs(data);
6350
+ if (!seqs)
5203
6351
  return false;
5204
6352
  if (!ch.children.has(fromHash))
5205
6353
  return true;
5206
- const count = data[37];
5207
- const max = Math.min(count, Math.floor((data.length - 38) / 4));
5208
- for (let i = 0; i < max; i++) {
5209
- const seq = readU32BE(data, 38 + i * 4);
6354
+ for (const seq of seqs) {
5210
6355
  const cached = this.getCached(ch, seq);
5211
6356
  if (!cached) {
5212
6357
  ch.metrics.cacheMissesServed += 1;
@@ -5218,13 +6363,11 @@ export class FanoutTree extends DirectStream {
5218
6363
  return true;
5219
6364
  }
5220
6365
  if (kind === MSG_FETCH_REQ) {
5221
- if (data.length < 1 + 32 + 4 + 1)
6366
+ const seqs = this.codec.decodeRepairSeqs(data);
6367
+ if (!seqs)
5222
6368
  return false;
5223
6369
  this.touchPeerHint(ch, fromHash);
5224
- const count = data[37];
5225
- const max = Math.min(count, Math.floor((data.length - 38) / 4));
5226
- for (let i = 0; i < max; i++) {
5227
- const seq = readU32BE(data, 38 + i * 4);
6370
+ for (const seq of seqs) {
5228
6371
  const cached = this.getCached(ch, seq);
5229
6372
  if (!cached) {
5230
6373
  ch.metrics.cacheMissesServed += 1;
@@ -5248,10 +6391,100 @@ export class FanoutTree extends DirectStream {
5248
6391
  return false;
5249
6392
  const seq = readU32BE(id, 4);
5250
6393
  if (ch.parent && fromHash === ch.parent) {
5251
- ch.lastParentDataAt = Date.now();
6394
+ const receivedAt = Date.now();
6395
+ ch.lastParentDataAt = receivedAt;
6396
+ ch.lastParentUpgradeActivityAt = receivedAt;
5252
6397
  ch.receivedAnyParentData = true;
6398
+ ch.parentRepairUnansweredStreak = 0;
6399
+ }
6400
+ const hadCachedBeforeData = this.getCached(ch, seq) != null;
6401
+ if (ch.parent && fromHash === ch.parent && !hadCachedBeforeData) {
6402
+ this.noteParentDataLatency(ch, message.header.timestamp);
5253
6403
  }
5254
6404
  ch.maxSeqSeen = Math.max(ch.maxSeqSeen, seq);
6405
+ if (ch.parentUpgradeRetryAfterSeq >= 0 &&
6406
+ seq > ch.parentUpgradeRetryAfterSeq) {
6407
+ ch.parentUpgradeRetryAfterSeq = -1;
6408
+ }
6409
+ const parentShadow = ch.parentShadow;
6410
+ if (parentShadow != null) {
6411
+ const now = Date.now();
6412
+ const pruneShadowSeqMap = (map) => {
6413
+ while (map.size > 512) {
6414
+ const oldest = map.keys().next().value;
6415
+ if (oldest == null)
6416
+ break;
6417
+ map.delete(oldest);
6418
+ }
6419
+ };
6420
+ const pruneShadowSeqSet = (set) => {
6421
+ while (set.size > 512) {
6422
+ const oldest = set.keys().next().value;
6423
+ if (oldest == null)
6424
+ break;
6425
+ set.delete(oldest);
6426
+ }
6427
+ };
6428
+ const rememberShadowArrival = (map) => {
6429
+ if (map.has(seq))
6430
+ return map.get(seq);
6431
+ map.set(seq, now);
6432
+ pruneShadowSeqMap(map);
6433
+ return now;
6434
+ };
6435
+ const recordCandidateLead = (candidateAt, parentAt) => {
6436
+ if (candidateAt == null ||
6437
+ parentAt == null ||
6438
+ parentShadow.liveComparedSeqs.has(seq)) {
6439
+ return;
6440
+ }
6441
+ parentShadow.liveComparedSeqs.add(seq);
6442
+ pruneShadowSeqSet(parentShadow.liveComparedSeqs);
6443
+ if (candidateAt < parentAt) {
6444
+ parentShadow.liveCandidateLeadSamples += 1;
6445
+ parentShadow.liveCandidateLeadMsTotal += parentAt - candidateAt;
6446
+ }
6447
+ };
6448
+ if (parentShadow.hash === fromHash) {
6449
+ const candidateAt = rememberShadowArrival(parentShadow.liveCandidateFirstAtBySeq);
6450
+ recordCandidateLead(candidateAt, parentShadow.liveParentFirstAtBySeq.get(seq));
6451
+ if (seq > parentShadow.liveMaxSeqSeen) {
6452
+ parentShadow.liveMaxSeqSeen = seq;
6453
+ parentShadow.liveDataMessages += 1;
6454
+ }
6455
+ if (!hadCachedBeforeData) {
6456
+ parentShadow.liveFirstDataMessages += 1;
6457
+ }
6458
+ parentShadow.liveLastDataAt = now;
6459
+ }
6460
+ else if (ch.parent != null && fromHash === ch.parent) {
6461
+ const parentAt = rememberShadowArrival(parentShadow.liveParentFirstAtBySeq);
6462
+ recordCandidateLead(parentShadow.liveCandidateFirstAtBySeq.get(seq), parentAt);
6463
+ if (!hadCachedBeforeData) {
6464
+ parentShadow.liveParentFirstDataMessages += 1;
6465
+ }
6466
+ }
6467
+ }
6468
+ const parentUpgradeGrace = ch.parentUpgradeGrace;
6469
+ if (parentUpgradeGrace != null && !hadCachedBeforeData) {
6470
+ if (fromHash === parentUpgradeGrace.candidateParent) {
6471
+ parentUpgradeGrace.candidateFirstMessages += 1;
6472
+ }
6473
+ else if (fromHash === parentUpgradeGrace.previousParent) {
6474
+ parentUpgradeGrace.previousFirstMessages += 1;
6475
+ }
6476
+ if (parentUpgradeGrace.previousFirstMessages >
6477
+ parentUpgradeGrace.maxPreviousFirstMessages) {
6478
+ this.rollbackParentUpgradeGrace(ch);
6479
+ }
6480
+ else if (parentUpgradeGrace.candidateFirstMessages >=
6481
+ parentUpgradeGrace.minCandidateFirstMessages &&
6482
+ parentUpgradeGrace.candidateFirstMessages >=
6483
+ parentUpgradeGrace.previousFirstMessages +
6484
+ parentUpgradeGrace.minCandidateAdvantageMessages) {
6485
+ this.commitParentUpgradeGrace(ch);
6486
+ }
6487
+ }
5255
6488
  this.noteReceivedSeq(ch, fromHash, seq);
5256
6489
  const payload = data.subarray(1);
5257
6490
  ch.metrics.dataReceives += 1;