@torrent-tv/proxy 2.62.0 → 2.63.0
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.
- package/CHANGELOG.md +8 -0
- package/bin/cli.js +520 -512
- package/package.json +1 -1
- package/services/delivery-probe.js +532 -480
- package/services/memory-report.js +120 -15
- package/services/piece-store/shared-piece-store.js +866 -791
- package/services/torrent-worker/worker.js +738 -706
- package/test/delivery-probe.test.js +213 -158
- package/test/memory-budget.test.js +89 -2
|
@@ -1,480 +1,532 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Numbered delivery probes, and the verdict they make possible.
|
|
3
|
-
*
|
|
4
|
-
* The delivery freeze (roadmap item 11) looks identical from the proxy in two
|
|
5
|
-
* cases that need opposite fixes: usrsctp stopped transmitting, or the browser
|
|
6
|
-
* closed its receive window because the page stopped draining the channel. The
|
|
7
|
-
* proxy's own counters cannot separate them — libdatachannel's `bytesSent`
|
|
8
|
-
* counts bytes ACCEPTED into usrsctp, not bytes put on the wire — so the
|
|
9
|
-
* reading has to come from the far end.
|
|
10
|
-
*
|
|
11
|
-
* Two facts make that cheap. The reverse direction keeps working throughout the
|
|
12
|
-
* freeze (browser to proxy requests arrive and are answered for the whole
|
|
13
|
-
* episode, 88 min in the 2026-08-24 case), so the browser can always report.
|
|
14
|
-
* And SCTP orders per STREAM, so a probe on a channel opened UNORDERED and
|
|
15
|
-
* WITHOUT retransmission passes head-of-line blocking in another stream but
|
|
16
|
-
* neither a closed receive window nor a transmitter that stopped.
|
|
17
|
-
*
|
|
18
|
-
* So: number a probe every {@link PROBE_INTERVAL_MS} on every channel of the
|
|
19
|
-
* connection, have the browser echo back the highest number it has seen on
|
|
20
|
-
* each, and read the gaps:
|
|
21
|
-
*
|
|
22
|
-
* every channel current to flowing
|
|
23
|
-
* ordered behind, unreliable current to a retransmission stuck in a stream
|
|
24
|
-
* both behind, echoes still arriving to the association stopped transmitting
|
|
25
|
-
* no echo at all to the reverse direction went too
|
|
26
|
-
*
|
|
27
|
-
* The verdict is computed from the gaps, not chosen, and every line prints the
|
|
28
|
-
* numbers that produced it.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* How often a probe is numbered and sent on every channel.
|
|
33
|
-
*
|
|
34
|
-
* Half a second, because the transport heartbeat is five and that was the whole
|
|
35
|
-
* resolution the 2026-08-24 episode had: the onset could be placed no closer
|
|
36
|
-
* than the five seconds between two lines. Sending is cheap - a probe is a few
|
|
37
|
-
* dozen bytes - and the interval is NOT the verdict: a healthy burst can hold a
|
|
38
|
-
* probe up behind queued data, so how many probes may be outstanding is worked
|
|
39
|
-
* out per channel by {@link allowedGap} from the bytes queued ahead of the
|
|
40
|
-
* probe and the rate they are leaving at.
|
|
41
|
-
*/
|
|
42
|
-
export const PROBE_INTERVAL_MS = 500;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* How many probes may be outstanding on a channel before it counts as behind.
|
|
46
|
-
*
|
|
47
|
-
* DERIVED per channel, not chosen. A probe is handed to the same association as
|
|
48
|
-
* the data, and SCTP orders per stream but SCHEDULES per association: one
|
|
49
|
-
* congestion window, one send buffer. So a probe waits for whatever is queued
|
|
50
|
-
* ahead of it whichever channel it rides on - the unordered one included - and
|
|
51
|
-
* a fixed threshold cannot tell that wait from a stopped association. Measured
|
|
52
|
-
* 2026-08-26: `association-stopped` printed with all three channels at gap 4-7
|
|
53
|
-
* while 110-150 Mbps crossed that same association and 7.34 GB went through it
|
|
54
|
-
* without a single failure.
|
|
55
|
-
*
|
|
56
|
-
* What the wait costs is arithmetic on two measured quantities: the bytes
|
|
57
|
-
* queued ahead of the probe, and the rate at which this connection is getting
|
|
58
|
-
* bytes away. Add one round trip for the echo to come back. A probe is behind
|
|
59
|
-
* only when it is later than that.
|
|
60
|
-
*
|
|
61
|
-
* There is a third term, and leaving it out made this worse than the constant
|
|
62
|
-
* it replaced. The browser answers on ITS own schedule, not ours: it batches
|
|
63
|
-
* what it has seen and echoes on a timer, and that timer is throttled to about
|
|
64
|
-
* once a second whenever the tab is hidden. So with an empty queue the
|
|
65
|
-
* allowance collapsed to one probe and the bound to half a second, while echoes
|
|
66
|
-
* legitimately arrived every second - measured 2026-08-27, 67 `association-
|
|
67
|
-
* stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection
|
|
68
|
-
* carrying 3.4 MB/s with every queue at zero. The peer's own cadence is
|
|
69
|
-
* measurable on the same connection, so it is measured and added rather than
|
|
70
|
-
* assumed.
|
|
71
|
-
*
|
|
72
|
-
* @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
|
|
73
|
-
* @returns {number | null} Probes that may legitimately be outstanding, or null
|
|
74
|
-
* when no rate has been measured yet and nothing can be said.
|
|
75
|
-
*/
|
|
76
|
-
export function allowedGap({
|
|
77
|
-
queuedBytes,
|
|
78
|
-
bytesPerSecond,
|
|
79
|
-
rttMs,
|
|
80
|
-
echoIntervalMs = 0,
|
|
81
|
-
intervalMs = PROBE_INTERVAL_MS
|
|
82
|
-
}) {
|
|
83
|
-
if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
|
|
87
|
-
const waitMs = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
|
|
88
|
-
// At least one: a probe sent and not yet echoed is the ordinary state.
|
|
89
|
-
return Math.max(1, Math.ceil(waitMs / intervalMs));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** The label the browser gives the unordered, non-retransmitting channel. */
|
|
93
|
-
export const UNRELIABLE_LABEL = "proxy-fast";
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* How old an echo may be before the reverse direction counts as gone, when
|
|
97
|
-
* nothing better can be derived.
|
|
98
|
-
*
|
|
99
|
-
* Used only while no rate has been measured. Otherwise the caller passes
|
|
100
|
-
* `echoStaleMs`, worked out the same way as {@link allowedGap}: the browser
|
|
101
|
-
* only echoes a probe it has RECEIVED, so an echo waits behind our own queue
|
|
102
|
-
* exactly as the probe did.
|
|
103
|
-
*/
|
|
104
|
-
const ECHO_STALE_FALLBACK_MS = 5_000;
|
|
105
|
-
|
|
106
|
-
/** How often the probe state is written to the log while nothing changes. */
|
|
107
|
-
const REPORT_INTERVAL_MS = 5_000;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Whether the `seen` counter has stopped advancing for longer than this
|
|
111
|
-
* connection's own history says a healthy gap between two advances ever
|
|
112
|
-
* takes.
|
|
113
|
-
*
|
|
114
|
-
* The `association-stopped` verdict alone is not enough to act on: a
|
|
115
|
-
* connection can sit BEHIND by a bounded, roughly constant amount for
|
|
116
|
-
* minutes (measured 2026-08-28, session on a backgrounded tab — gap held at
|
|
117
|
-
* 6-7 probes for 95+ seconds while `seen` kept climbing right along with
|
|
118
|
-
* `sent`) without anything being wrong. What a true wedge shows instead,
|
|
119
|
-
* measured the same day against a session already known to be one
|
|
120
|
-
* (`d85ae4f5`): `seen` FROZEN at one value for over a minute while `sent`
|
|
121
|
-
* climbs unbounded. So the question is not "is there a gap" but "has the
|
|
122
|
-
* highest-seen number stopped moving at all, for longer than it has ever
|
|
123
|
-
* legitimately taken this connection to report an advance" — the same shape
|
|
124
|
-
* as {@link wedgeIsCertain} in `data-channel-handler.js`, applied to the
|
|
125
|
-
* probe's own counter instead of the transport's byte counter.
|
|
126
|
-
*
|
|
127
|
-
* @param {{ stuckForMs: number, longestHealthySeenGapMs: number, intervalMs?: number }} state
|
|
128
|
-
* @returns {{ certain: boolean, needMs: number }}
|
|
129
|
-
*/
|
|
130
|
-
export function probeWedgeIsCertain({ stuckForMs, longestHealthySeenGapMs, intervalMs = PROBE_INTERVAL_MS }) {
|
|
131
|
-
const needMs = Math.max(longestHealthySeenGapMs, intervalMs);
|
|
132
|
-
return { certain: stuckForMs >= needMs, needMs };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* One connection's probe state.
|
|
137
|
-
*
|
|
138
|
-
* @typedef {Object} ProbeConnection
|
|
139
|
-
* @property {string} tag
|
|
140
|
-
* @property {Map<import('node-datachannel').DataChannel, string>} channels
|
|
141
|
-
* @property {number} seq - Highest probe number sent.
|
|
142
|
-
* @property {number} sentAt - When that probe was sent.
|
|
143
|
-
* @property {Map<string, number>} seen - Label to the highest number the browser reported.
|
|
144
|
-
* @property {number} echoAt - When the last echo arrived (0 = never).
|
|
145
|
-
* @property {number} echoes - How many echoes have arrived.
|
|
146
|
-
* @property {string} verdict - Last verdict reported, so a change is logged at once.
|
|
147
|
-
* @property {number} reportedAt - When the state was last written to the log.
|
|
148
|
-
* @property {number} lastSeenAdvanceAt - When any label's `seen` value last increased (0 = never yet).
|
|
149
|
-
* @property {number} longestHealthySeenGapMs - The longest gap between two advances this connection has shown while not flagged as a wedge.
|
|
150
|
-
* @property {
|
|
151
|
-
* @property {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
connection
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
},
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Numbered delivery probes, and the verdict they make possible.
|
|
3
|
+
*
|
|
4
|
+
* The delivery freeze (roadmap item 11) looks identical from the proxy in two
|
|
5
|
+
* cases that need opposite fixes: usrsctp stopped transmitting, or the browser
|
|
6
|
+
* closed its receive window because the page stopped draining the channel. The
|
|
7
|
+
* proxy's own counters cannot separate them — libdatachannel's `bytesSent`
|
|
8
|
+
* counts bytes ACCEPTED into usrsctp, not bytes put on the wire — so the
|
|
9
|
+
* reading has to come from the far end.
|
|
10
|
+
*
|
|
11
|
+
* Two facts make that cheap. The reverse direction keeps working throughout the
|
|
12
|
+
* freeze (browser to proxy requests arrive and are answered for the whole
|
|
13
|
+
* episode, 88 min in the 2026-08-24 case), so the browser can always report.
|
|
14
|
+
* And SCTP orders per STREAM, so a probe on a channel opened UNORDERED and
|
|
15
|
+
* WITHOUT retransmission passes head-of-line blocking in another stream but
|
|
16
|
+
* neither a closed receive window nor a transmitter that stopped.
|
|
17
|
+
*
|
|
18
|
+
* So: number a probe every {@link PROBE_INTERVAL_MS} on every channel of the
|
|
19
|
+
* connection, have the browser echo back the highest number it has seen on
|
|
20
|
+
* each, and read the gaps:
|
|
21
|
+
*
|
|
22
|
+
* every channel current to flowing
|
|
23
|
+
* ordered behind, unreliable current to a retransmission stuck in a stream
|
|
24
|
+
* both behind, echoes still arriving to the association stopped transmitting
|
|
25
|
+
* no echo at all to the reverse direction went too
|
|
26
|
+
*
|
|
27
|
+
* The verdict is computed from the gaps, not chosen, and every line prints the
|
|
28
|
+
* numbers that produced it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How often a probe is numbered and sent on every channel.
|
|
33
|
+
*
|
|
34
|
+
* Half a second, because the transport heartbeat is five and that was the whole
|
|
35
|
+
* resolution the 2026-08-24 episode had: the onset could be placed no closer
|
|
36
|
+
* than the five seconds between two lines. Sending is cheap - a probe is a few
|
|
37
|
+
* dozen bytes - and the interval is NOT the verdict: a healthy burst can hold a
|
|
38
|
+
* probe up behind queued data, so how many probes may be outstanding is worked
|
|
39
|
+
* out per channel by {@link allowedGap} from the bytes queued ahead of the
|
|
40
|
+
* probe and the rate they are leaving at.
|
|
41
|
+
*/
|
|
42
|
+
export const PROBE_INTERVAL_MS = 500;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many probes may be outstanding on a channel before it counts as behind.
|
|
46
|
+
*
|
|
47
|
+
* DERIVED per channel, not chosen. A probe is handed to the same association as
|
|
48
|
+
* the data, and SCTP orders per stream but SCHEDULES per association: one
|
|
49
|
+
* congestion window, one send buffer. So a probe waits for whatever is queued
|
|
50
|
+
* ahead of it whichever channel it rides on - the unordered one included - and
|
|
51
|
+
* a fixed threshold cannot tell that wait from a stopped association. Measured
|
|
52
|
+
* 2026-08-26: `association-stopped` printed with all three channels at gap 4-7
|
|
53
|
+
* while 110-150 Mbps crossed that same association and 7.34 GB went through it
|
|
54
|
+
* without a single failure.
|
|
55
|
+
*
|
|
56
|
+
* What the wait costs is arithmetic on two measured quantities: the bytes
|
|
57
|
+
* queued ahead of the probe, and the rate at which this connection is getting
|
|
58
|
+
* bytes away. Add one round trip for the echo to come back. A probe is behind
|
|
59
|
+
* only when it is later than that.
|
|
60
|
+
*
|
|
61
|
+
* There is a third term, and leaving it out made this worse than the constant
|
|
62
|
+
* it replaced. The browser answers on ITS own schedule, not ours: it batches
|
|
63
|
+
* what it has seen and echoes on a timer, and that timer is throttled to about
|
|
64
|
+
* once a second whenever the tab is hidden. So with an empty queue the
|
|
65
|
+
* allowance collapsed to one probe and the bound to half a second, while echoes
|
|
66
|
+
* legitimately arrived every second - measured 2026-08-27, 67 `association-
|
|
67
|
+
* stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection
|
|
68
|
+
* carrying 3.4 MB/s with every queue at zero. The peer's own cadence is
|
|
69
|
+
* measurable on the same connection, so it is measured and added rather than
|
|
70
|
+
* assumed.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
|
|
73
|
+
* @returns {number | null} Probes that may legitimately be outstanding, or null
|
|
74
|
+
* when no rate has been measured yet and nothing can be said.
|
|
75
|
+
*/
|
|
76
|
+
export function allowedGap({
|
|
77
|
+
queuedBytes,
|
|
78
|
+
bytesPerSecond,
|
|
79
|
+
rttMs,
|
|
80
|
+
echoIntervalMs = 0,
|
|
81
|
+
intervalMs = PROBE_INTERVAL_MS
|
|
82
|
+
}) {
|
|
83
|
+
if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
|
|
87
|
+
const waitMs = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
|
|
88
|
+
// At least one: a probe sent and not yet echoed is the ordinary state.
|
|
89
|
+
return Math.max(1, Math.ceil(waitMs / intervalMs));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The label the browser gives the unordered, non-retransmitting channel. */
|
|
93
|
+
export const UNRELIABLE_LABEL = "proxy-fast";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How old an echo may be before the reverse direction counts as gone, when
|
|
97
|
+
* nothing better can be derived.
|
|
98
|
+
*
|
|
99
|
+
* Used only while no rate has been measured. Otherwise the caller passes
|
|
100
|
+
* `echoStaleMs`, worked out the same way as {@link allowedGap}: the browser
|
|
101
|
+
* only echoes a probe it has RECEIVED, so an echo waits behind our own queue
|
|
102
|
+
* exactly as the probe did.
|
|
103
|
+
*/
|
|
104
|
+
const ECHO_STALE_FALLBACK_MS = 5_000;
|
|
105
|
+
|
|
106
|
+
/** How often the probe state is written to the log while nothing changes. */
|
|
107
|
+
const REPORT_INTERVAL_MS = 5_000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether the `seen` counter has stopped advancing for longer than this
|
|
111
|
+
* connection's own history says a healthy gap between two advances ever
|
|
112
|
+
* takes.
|
|
113
|
+
*
|
|
114
|
+
* The `association-stopped` verdict alone is not enough to act on: a
|
|
115
|
+
* connection can sit BEHIND by a bounded, roughly constant amount for
|
|
116
|
+
* minutes (measured 2026-08-28, session on a backgrounded tab — gap held at
|
|
117
|
+
* 6-7 probes for 95+ seconds while `seen` kept climbing right along with
|
|
118
|
+
* `sent`) without anything being wrong. What a true wedge shows instead,
|
|
119
|
+
* measured the same day against a session already known to be one
|
|
120
|
+
* (`d85ae4f5`): `seen` FROZEN at one value for over a minute while `sent`
|
|
121
|
+
* climbs unbounded. So the question is not "is there a gap" but "has the
|
|
122
|
+
* highest-seen number stopped moving at all, for longer than it has ever
|
|
123
|
+
* legitimately taken this connection to report an advance" — the same shape
|
|
124
|
+
* as {@link wedgeIsCertain} in `data-channel-handler.js`, applied to the
|
|
125
|
+
* probe's own counter instead of the transport's byte counter.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ stuckForMs: number, longestHealthySeenGapMs: number, intervalMs?: number }} state
|
|
128
|
+
* @returns {{ certain: boolean, needMs: number }}
|
|
129
|
+
*/
|
|
130
|
+
export function probeWedgeIsCertain({ stuckForMs, longestHealthySeenGapMs, intervalMs = PROBE_INTERVAL_MS }) {
|
|
131
|
+
const needMs = Math.max(longestHealthySeenGapMs, intervalMs);
|
|
132
|
+
return { certain: stuckForMs >= needMs, needMs };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One connection's probe state.
|
|
137
|
+
*
|
|
138
|
+
* @typedef {Object} ProbeConnection
|
|
139
|
+
* @property {string} tag
|
|
140
|
+
* @property {Map<import('node-datachannel').DataChannel, string>} channels
|
|
141
|
+
* @property {number} seq - Highest probe number sent.
|
|
142
|
+
* @property {number} sentAt - When that probe was sent.
|
|
143
|
+
* @property {Map<string, number>} seen - Label to the highest number the browser reported.
|
|
144
|
+
* @property {number} echoAt - When the last echo arrived (0 = never).
|
|
145
|
+
* @property {number} echoes - How many echoes have arrived.
|
|
146
|
+
* @property {string} verdict - Last verdict reported, so a change is logged at once.
|
|
147
|
+
* @property {number} reportedAt - When the state was last written to the log.
|
|
148
|
+
* @property {number} lastSeenAdvanceAt - When any label's `seen` value last increased (0 = never yet).
|
|
149
|
+
* @property {number} longestHealthySeenGapMs - The longest gap between two advances this connection has shown while not flagged as a wedge.
|
|
150
|
+
* @property {number | null} peerBytes - The far end's transport-level received total, as last reported.
|
|
151
|
+
* @property {number | null} peerBytesAtTick - The same, as it stood at the previous tick.
|
|
152
|
+
* @property {boolean} probeCaptureStarted - One evidence-gathering attempt per wedge; reset once `seen` advances again.
|
|
153
|
+
* @property {ReturnType<typeof setInterval> | null} timer
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read the gaps and say what they mean.
|
|
158
|
+
*
|
|
159
|
+
* Exported so the rule is testable without a connection: the same numbers
|
|
160
|
+
* always produce the same word.
|
|
161
|
+
*
|
|
162
|
+
* `allowed` carries, per channel label, how many probes may legitimately be
|
|
163
|
+
* outstanding right now — {@link allowedGap} computes it from that channel's
|
|
164
|
+
* own queue and the connection's measured rate. A label with no entry, or an
|
|
165
|
+
* entry of null, cannot be judged: with no rate measured there is nothing to
|
|
166
|
+
* divide the queue by, and the verdict says that rather than inventing one.
|
|
167
|
+
*
|
|
168
|
+
* `peerBytesAdvancing` is the fact that outranks every gap here. The gaps are
|
|
169
|
+
* counted against an allowance whose only load-dependent term is OUR OWN
|
|
170
|
+
* queue — and that queue is empty by construction, because it drains the moment
|
|
171
|
+
* libdatachannel accepts the bytes, whether or not usrsctp then puts them on
|
|
172
|
+
* the wire. So a browser filling its cushion as fast as the link allows shows
|
|
173
|
+
* an empty queue, a small allowance and a large gap, which reads exactly like a
|
|
174
|
+
* stopped association. Measured 2026-08-28: four `association-stopped` in the
|
|
175
|
+
* first two minutes of a healthy session, on a connection whose every queue was
|
|
176
|
+
* at 0 B and whose viewer never saw the picture stop. Deepening the browser's
|
|
177
|
+
* cushion from 30 s to 120 s (roadmap item 4) made the burst four times longer
|
|
178
|
+
* and the false verdict correspondingly likelier.
|
|
179
|
+
*
|
|
180
|
+
* What separates the two is not the size of the backlog — it is large in both —
|
|
181
|
+
* but whether bytes are still arriving. The browser reports its own
|
|
182
|
+
* transport-level received total with every echo, so the question is answered
|
|
183
|
+
* by a counter rather than by a threshold: while that total is advancing, bytes
|
|
184
|
+
* ARE crossing and no verdict of a stopped association can stand, however far
|
|
185
|
+
* behind the probes are. `null` where the far end does not report it, and then
|
|
186
|
+
* the rule falls back to what it says without the term.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ seq: number, seen: Map<string, number> | Record<string, number>, labels: string[], echoes: number, echoAgeMs: number | null, allowed?: Map<string, number | null> | Record<string, number | null>, echoStaleMs?: number, peerBytesAdvancing?: boolean | null }} state
|
|
189
|
+
* @returns {{ verdict: string, detail: string }}
|
|
190
|
+
*/
|
|
191
|
+
export function readProbeState(state) {
|
|
192
|
+
const seenOf = (label) =>
|
|
193
|
+
state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
|
|
194
|
+
const allowedOf = (label) => {
|
|
195
|
+
const source = state.allowed;
|
|
196
|
+
const value = source instanceof Map ? source.get(label) : source?.[label];
|
|
197
|
+
return Number.isInteger(value) ? Number(value) : null;
|
|
198
|
+
};
|
|
199
|
+
const parts = [];
|
|
200
|
+
let orderedBehind = false;
|
|
201
|
+
let unreliableBehind = false;
|
|
202
|
+
let unreliableKnown = false;
|
|
203
|
+
let judgeable = false;
|
|
204
|
+
for (const label of state.labels) {
|
|
205
|
+
const seen = seenOf(label);
|
|
206
|
+
const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
|
|
207
|
+
const allowance = allowedOf(label);
|
|
208
|
+
parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"} of ${allowance ?? "?"})`);
|
|
209
|
+
if (allowance === null) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
judgeable = true;
|
|
213
|
+
const behind = gap === null || gap > allowance;
|
|
214
|
+
if (label === UNRELIABLE_LABEL) {
|
|
215
|
+
unreliableKnown = true;
|
|
216
|
+
unreliableBehind = behind;
|
|
217
|
+
} else if (behind) {
|
|
218
|
+
orderedBehind = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const advancing = state.peerBytesAdvancing === true;
|
|
222
|
+
const detail =
|
|
223
|
+
`sent=${state.seq} ${parts.join(" ")} ` +
|
|
224
|
+
`echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}` +
|
|
225
|
+
(state.peerBytesAdvancing === null || state.peerBytesAdvancing === undefined
|
|
226
|
+
? ""
|
|
227
|
+
: ` peerBytes=${advancing ? "advancing" : "still"}`);
|
|
228
|
+
|
|
229
|
+
if (state.echoes === 0) {
|
|
230
|
+
return { verdict: "no-echo-yet", detail };
|
|
231
|
+
}
|
|
232
|
+
if (!judgeable) {
|
|
233
|
+
return { verdict: "no-rate-yet", detail };
|
|
234
|
+
}
|
|
235
|
+
const staleAfterMs = Number.isFinite(state.echoStaleMs) && state.echoStaleMs > 0
|
|
236
|
+
? state.echoStaleMs
|
|
237
|
+
: ECHO_STALE_FALLBACK_MS;
|
|
238
|
+
if (state.echoAgeMs !== null && state.echoAgeMs > staleAfterMs) {
|
|
239
|
+
return { verdict: "reverse-direction-gone", detail };
|
|
240
|
+
}
|
|
241
|
+
if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
|
|
242
|
+
return { verdict: "flowing", detail };
|
|
243
|
+
}
|
|
244
|
+
// Bytes are still arriving at the far end. Whatever the probe gaps say, this
|
|
245
|
+
// association has not stopped — the probes are behind a backlog, which is
|
|
246
|
+
// what filling a cushion looks like from here.
|
|
247
|
+
if (advancing) {
|
|
248
|
+
return { verdict: "flowing", detail };
|
|
249
|
+
}
|
|
250
|
+
if (orderedBehind && unreliableKnown && !unreliableBehind) {
|
|
251
|
+
return { verdict: "stream-stuck", detail };
|
|
252
|
+
}
|
|
253
|
+
if (orderedBehind) {
|
|
254
|
+
return {
|
|
255
|
+
verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
|
|
256
|
+
detail
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return { verdict: "unreliable-behind-only", detail };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create the probe service. One instance serves every session.
|
|
264
|
+
*
|
|
265
|
+
* @param {Object} options
|
|
266
|
+
* @param {(message: string) => void} options.log
|
|
267
|
+
* @param {number} [options.intervalMs]
|
|
268
|
+
* @param {(sessionId: string) => object | null} [options.getTransportSnapshot]
|
|
269
|
+
* Needed only to hand the witness a remote endpoint when this probe is the
|
|
270
|
+
* one declaring a wedge.
|
|
271
|
+
* @param {{ maybeCapture: (trigger: object) => boolean }} [options.witness]
|
|
272
|
+
* @param {{ maybeRead: (reasonText: string) => boolean }} [options.usrsctpState]
|
|
273
|
+
* @returns {{
|
|
274
|
+
* attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
|
|
275
|
+
* detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
|
|
276
|
+
* noteEcho: (sessionId: string, echo: object) => void,
|
|
277
|
+
* dispose: () => void
|
|
278
|
+
* }}
|
|
279
|
+
*/
|
|
280
|
+
export function createDeliveryProbe({
|
|
281
|
+
log,
|
|
282
|
+
intervalMs = PROBE_INTERVAL_MS,
|
|
283
|
+
readDelivery,
|
|
284
|
+
getTransportSnapshot,
|
|
285
|
+
witness,
|
|
286
|
+
usrsctpState
|
|
287
|
+
}) {
|
|
288
|
+
/** @type {Map<string, ProbeConnection>} */
|
|
289
|
+
const connections = new Map();
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Send this tick's probe on every channel of one connection, then report.
|
|
293
|
+
*
|
|
294
|
+
* @param {ProbeConnection} connection
|
|
295
|
+
* @returns {void}
|
|
296
|
+
*/
|
|
297
|
+
function tick(connection) {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
connection.seq += 1;
|
|
300
|
+
connection.sentAt = now;
|
|
301
|
+
const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
|
|
302
|
+
for (const channel of connection.channels.keys()) {
|
|
303
|
+
try {
|
|
304
|
+
channel.sendMessage(message);
|
|
305
|
+
} catch {
|
|
306
|
+
// A channel closing between the check and the send is ordinary.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// What this connection is getting away, and how far behind it therefore
|
|
311
|
+
// sits. Both come from the send-queue watcher, which measures them anyway.
|
|
312
|
+
const delivery = typeof readDelivery === "function" ? readDelivery(connection.id) : null;
|
|
313
|
+
const bytesPerSecond = Number(delivery?.bytesPerSecond) || 0;
|
|
314
|
+
const rttMs = Number(delivery?.rttMs) || 0;
|
|
315
|
+
/** @type {Map<string, number | null>} */
|
|
316
|
+
const allowed = new Map();
|
|
317
|
+
for (const [channel, label] of connection.channels) {
|
|
318
|
+
let queuedBytes = 0;
|
|
319
|
+
try {
|
|
320
|
+
queuedBytes = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
321
|
+
} catch {
|
|
322
|
+
queuedBytes = 0;
|
|
323
|
+
}
|
|
324
|
+
const allowance = allowedGap({
|
|
325
|
+
queuedBytes,
|
|
326
|
+
bytesPerSecond,
|
|
327
|
+
rttMs,
|
|
328
|
+
echoIntervalMs: connection.echoIntervalMs,
|
|
329
|
+
intervalMs
|
|
330
|
+
});
|
|
331
|
+
// Several channels can carry one label only in malformed cases; the
|
|
332
|
+
// larger allowance is the safer of the two.
|
|
333
|
+
const held = allowed.get(label);
|
|
334
|
+
if (allowance !== null && (!Number.isInteger(held) || allowance > held)) {
|
|
335
|
+
allowed.set(label, allowance);
|
|
336
|
+
} else if (!allowed.has(label)) {
|
|
337
|
+
allowed.set(label, allowance);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const widest = [...allowed.values()].reduce(
|
|
341
|
+
(most, value) => (Number.isInteger(value) && value > most ? value : most),
|
|
342
|
+
0
|
|
343
|
+
);
|
|
344
|
+
// Advancing SINCE THE PREVIOUS TICK, not since the connection began: the
|
|
345
|
+
// question is whether bytes are crossing now.
|
|
346
|
+
const peerBytesAdvancing = connection.peerBytes === null
|
|
347
|
+
? null
|
|
348
|
+
: connection.peerBytesAtTick === null || connection.peerBytes > connection.peerBytesAtTick;
|
|
349
|
+
connection.peerBytesAtTick = connection.peerBytes;
|
|
350
|
+
const { verdict, detail } = readProbeState({
|
|
351
|
+
seq: connection.seq,
|
|
352
|
+
seen: connection.seen,
|
|
353
|
+
peerBytesAdvancing,
|
|
354
|
+
labels: [...new Set(connection.channels.values())],
|
|
355
|
+
echoes: connection.echoes,
|
|
356
|
+
echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
|
|
357
|
+
allowed,
|
|
358
|
+
// Same arithmetic for the echo's own age: the peer cannot answer sooner
|
|
359
|
+
// than its own cadence allows, and a hidden tab's is about a second.
|
|
360
|
+
echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
|
|
361
|
+
});
|
|
362
|
+
if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
|
|
363
|
+
connection.verdict = verdict;
|
|
364
|
+
connection.reportedAt = now;
|
|
365
|
+
log(`[dc-probe] ${connection.tag} ${verdict} — ${detail} at=${new Date(now).toISOString()}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// `association-stopped` alone is not certainty — see probeWedgeIsCertain.
|
|
369
|
+
// A connection that is merely lagging by a bounded amount reaches this
|
|
370
|
+
// verdict too (a hidden tab's own echo cadence, measured 2026-08-28), and
|
|
371
|
+
// `seen` keeps advancing right along with it. Only a `seen` value that has
|
|
372
|
+
// stopped moving ENTIRELY, for longer than this connection has ever shown
|
|
373
|
+
// as a legitimate gap, is the wedge this exists to catch.
|
|
374
|
+
const stuckForMs = connection.lastSeenAdvanceAt === 0 ? 0 : now - connection.lastSeenAdvanceAt;
|
|
375
|
+
if (verdict === "association-stopped") {
|
|
376
|
+
const { certain, needMs } = probeWedgeIsCertain({
|
|
377
|
+
stuckForMs,
|
|
378
|
+
longestHealthySeenGapMs: connection.longestHealthySeenGapMs
|
|
379
|
+
});
|
|
380
|
+
if (certain && !connection.probeCaptureStarted) {
|
|
381
|
+
connection.probeCaptureStarted = true;
|
|
382
|
+
const reasonText =
|
|
383
|
+
`probe seen-counter unmoved ${Math.round(stuckForMs / 1000)}s against the ` +
|
|
384
|
+
`${(needMs / 1000).toFixed(1)}s this connection's own history says is legitimate`;
|
|
385
|
+
if (witness) {
|
|
386
|
+
const snapshot = getTransportSnapshot?.(connection.id) ?? null;
|
|
387
|
+
const started = witness.maybeCapture({
|
|
388
|
+
sessionId: connection.id,
|
|
389
|
+
tag: connection.tag,
|
|
390
|
+
label: "probe",
|
|
391
|
+
remote: snapshot?.remote ?? null,
|
|
392
|
+
queuedBytes: 0,
|
|
393
|
+
stuckForMs
|
|
394
|
+
});
|
|
395
|
+
if (!started) {
|
|
396
|
+
connection.probeCaptureStarted = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (usrsctpState) {
|
|
400
|
+
usrsctpState.maybeRead(reasonText);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
// Not association-stopped any more: whatever was flagged has cleared,
|
|
405
|
+
// and a later wedge on the same connection deserves its own attempt.
|
|
406
|
+
connection.probeCaptureStarted = false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
attach(sessionId, tag, label, channel) {
|
|
412
|
+
let connection = connections.get(sessionId);
|
|
413
|
+
if (!connection) {
|
|
414
|
+
connection = {
|
|
415
|
+
id: sessionId,
|
|
416
|
+
tag,
|
|
417
|
+
// The longest this peer has ever taken between two echoes. Its own
|
|
418
|
+
// schedule, measured rather than assumed - a hidden tab answers
|
|
419
|
+
// about once a second because the browser throttles the timer.
|
|
420
|
+
echoIntervalMs: 0,
|
|
421
|
+
channels: new Map(),
|
|
422
|
+
seq: 0,
|
|
423
|
+
sentAt: 0,
|
|
424
|
+
seen: new Map(),
|
|
425
|
+
echoAt: 0,
|
|
426
|
+
echoes: 0,
|
|
427
|
+
// The far end's own transport-level received total, and its value at
|
|
428
|
+
// the previous tick. Null until a browser that reports it has echoed.
|
|
429
|
+
/** @type {number | null} */
|
|
430
|
+
peerBytes: null,
|
|
431
|
+
/** @type {number | null} */
|
|
432
|
+
peerBytesAtTick: null,
|
|
433
|
+
verdict: "",
|
|
434
|
+
reportedAt: 0,
|
|
435
|
+
lastSeenAdvanceAt: 0,
|
|
436
|
+
longestHealthySeenGapMs: 0,
|
|
437
|
+
probeCaptureStarted: false,
|
|
438
|
+
timer: null
|
|
439
|
+
};
|
|
440
|
+
connections.set(sessionId, connection);
|
|
441
|
+
}
|
|
442
|
+
connection.channels.set(channel, label);
|
|
443
|
+
if (connection.timer === null) {
|
|
444
|
+
const held = connection;
|
|
445
|
+
connection.timer = setInterval(() => tick(held), intervalMs);
|
|
446
|
+
// The probe must never be the reason a process stays alive.
|
|
447
|
+
if (typeof connection.timer.unref === "function") {
|
|
448
|
+
connection.timer.unref();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
detach(sessionId, channel) {
|
|
454
|
+
const connection = connections.get(sessionId);
|
|
455
|
+
if (!connection) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
connection.channels.delete(channel);
|
|
459
|
+
if (connection.channels.size === 0) {
|
|
460
|
+
if (connection.timer !== null) {
|
|
461
|
+
clearInterval(connection.timer);
|
|
462
|
+
connection.timer = null;
|
|
463
|
+
}
|
|
464
|
+
if (connections.get(sessionId) === connection) {
|
|
465
|
+
connections.delete(sessionId);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
noteEcho(sessionId, echo) {
|
|
471
|
+
const connection = connections.get(sessionId);
|
|
472
|
+
if (!connection || !echo || typeof echo !== "object") {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
const seen = echo.seen;
|
|
477
|
+
if (seen && typeof seen === "object") {
|
|
478
|
+
let advanced = false;
|
|
479
|
+
for (const [label, value] of Object.entries(seen)) {
|
|
480
|
+
if (!Number.isInteger(value)) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const previous = connection.seen.get(label);
|
|
484
|
+
if (!Number.isInteger(previous) || value > previous) {
|
|
485
|
+
advanced = true;
|
|
486
|
+
}
|
|
487
|
+
connection.seen.set(label, value);
|
|
488
|
+
}
|
|
489
|
+
// What a wedge shows is this counter frozen, not merely behind — see
|
|
490
|
+
// probeWedgeIsCertain. The gap since the last time ANY label moved is
|
|
491
|
+
// this connection's own answer to "how long may a healthy report take
|
|
492
|
+
// to arrive", recorded only while nothing is currently flagged (the
|
|
493
|
+
// same guard `longestHealthyFlatMs` uses): a stretch already under
|
|
494
|
+
// suspicion must not teach the detector to tolerate it.
|
|
495
|
+
if (advanced) {
|
|
496
|
+
if (connection.lastSeenAdvanceAt !== 0 && !connection.probeCaptureStarted) {
|
|
497
|
+
const gap = now - connection.lastSeenAdvanceAt;
|
|
498
|
+
if (gap > connection.longestHealthySeenGapMs) {
|
|
499
|
+
connection.longestHealthySeenGapMs = gap;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
connection.lastSeenAdvanceAt = now;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// What the far end says it has received at the transport level. It is the
|
|
506
|
+
// one figure that separates a backlog from a stopped association, and it
|
|
507
|
+
// arrives on the direction that goes on working through a freeze.
|
|
508
|
+
const peerBytes = Number(echo?.report?.transportBytesReceived);
|
|
509
|
+
if (Number.isFinite(peerBytes) && peerBytes >= 0) {
|
|
510
|
+
connection.peerBytes = peerBytes;
|
|
511
|
+
}
|
|
512
|
+
if (connection.echoAt !== 0) {
|
|
513
|
+
const sinceLast = now - connection.echoAt;
|
|
514
|
+
if (sinceLast > connection.echoIntervalMs) {
|
|
515
|
+
connection.echoIntervalMs = sinceLast;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
connection.echoAt = now;
|
|
519
|
+
connection.echoes += 1;
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
dispose() {
|
|
523
|
+
for (const connection of connections.values()) {
|
|
524
|
+
if (connection.timer !== null) {
|
|
525
|
+
clearInterval(connection.timer);
|
|
526
|
+
connection.timer = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
connections.clear();
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|