@torrent-tv/proxy 2.62.0 → 2.64.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +17 -0
  3. package/bin/cli.js +520 -512
  4. package/docs/container-architecture.md +86 -0
  5. package/package.json +1 -1
  6. package/routes/api/playback-plan/post.js +5 -6
  7. package/routes/api/subtitles/get.js +39 -208
  8. package/services/container/AviContainer.js +45 -0
  9. package/services/container/Container.js +59 -0
  10. package/services/container/ContainerFactory.js +31 -0
  11. package/services/container/MatroskaContainer.js +289 -0
  12. package/services/container/Mp4Container.js +242 -0
  13. package/services/container/index.js +5 -0
  14. package/services/controllers/PlaybackController.js +33 -0
  15. package/services/controllers/SubtitleController.js +126 -0
  16. package/services/controllers/index.js +2 -0
  17. package/services/delivery-probe.js +532 -480
  18. package/services/memory-report.js +120 -15
  19. package/services/orchestrators/ContainerOrchestrator.js +89 -0
  20. package/services/orchestrators/SubtitleOrchestrator.js +101 -0
  21. package/services/orchestrators/index.js +2 -0
  22. package/services/piece-store/shared-piece-store.js +870 -791
  23. package/services/torrent-worker/worker.js +738 -706
  24. package/services/tracks/AudioTrack.js +40 -0
  25. package/services/tracks/ContainerTrack.js +72 -0
  26. package/services/tracks/ExternalSubtitleFile.js +27 -0
  27. package/services/tracks/ImageSubtitleTrack.js +19 -0
  28. package/services/tracks/SubtitleTrack.js +38 -0
  29. package/services/tracks/TextSubtitleTrack.js +29 -0
  30. package/services/tracks/VideoTrack.js +33 -0
  31. package/services/tracks/index.js +7 -0
  32. package/test/delivery-probe.test.js +213 -158
  33. package/test/memory-budget.test.js +89 -2
  34. package/test/worker-source-race.test.js +0 -76
@@ -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 {boolean} probeCaptureStarted - One evidence-gathering attempt per wedge; reset once `seen` advances again.
151
- * @property {ReturnType<typeof setInterval> | null} timer
152
- */
153
-
154
- /**
155
- * Read the gaps and say what they mean.
156
- *
157
- * Exported so the rule is testable without a connection: the same numbers
158
- * always produce the same word.
159
- *
160
- * `allowed` carries, per channel label, how many probes may legitimately be
161
- * outstanding right now — {@link allowedGap} computes it from that channel's
162
- * own queue and the connection's measured rate. A label with no entry, or an
163
- * entry of null, cannot be judged: with no rate measured there is nothing to
164
- * divide the queue by, and the verdict says that rather than inventing one.
165
- *
166
- * @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 }} state
167
- * @returns {{ verdict: string, detail: string }}
168
- */
169
- export function readProbeState(state) {
170
- const seenOf = (label) =>
171
- state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
172
- const allowedOf = (label) => {
173
- const source = state.allowed;
174
- const value = source instanceof Map ? source.get(label) : source?.[label];
175
- return Number.isInteger(value) ? Number(value) : null;
176
- };
177
- const parts = [];
178
- let orderedBehind = false;
179
- let unreliableBehind = false;
180
- let unreliableKnown = false;
181
- let judgeable = false;
182
- for (const label of state.labels) {
183
- const seen = seenOf(label);
184
- const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
185
- const allowance = allowedOf(label);
186
- parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"} of ${allowance ?? "?"})`);
187
- if (allowance === null) {
188
- continue;
189
- }
190
- judgeable = true;
191
- const behind = gap === null || gap > allowance;
192
- if (label === UNRELIABLE_LABEL) {
193
- unreliableKnown = true;
194
- unreliableBehind = behind;
195
- } else if (behind) {
196
- orderedBehind = true;
197
- }
198
- }
199
- const detail =
200
- `sent=${state.seq} ${parts.join(" ")} ` +
201
- `echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}`;
202
-
203
- if (state.echoes === 0) {
204
- return { verdict: "no-echo-yet", detail };
205
- }
206
- if (!judgeable) {
207
- return { verdict: "no-rate-yet", detail };
208
- }
209
- const staleAfterMs = Number.isFinite(state.echoStaleMs) && state.echoStaleMs > 0
210
- ? state.echoStaleMs
211
- : ECHO_STALE_FALLBACK_MS;
212
- if (state.echoAgeMs !== null && state.echoAgeMs > staleAfterMs) {
213
- return { verdict: "reverse-direction-gone", detail };
214
- }
215
- if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
216
- return { verdict: "flowing", detail };
217
- }
218
- if (orderedBehind && unreliableKnown && !unreliableBehind) {
219
- return { verdict: "stream-stuck", detail };
220
- }
221
- if (orderedBehind) {
222
- return {
223
- verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
224
- detail
225
- };
226
- }
227
- return { verdict: "unreliable-behind-only", detail };
228
- }
229
-
230
- /**
231
- * Create the probe service. One instance serves every session.
232
- *
233
- * @param {Object} options
234
- * @param {(message: string) => void} options.log
235
- * @param {number} [options.intervalMs]
236
- * @param {(sessionId: string) => object | null} [options.getTransportSnapshot]
237
- * Needed only to hand the witness a remote endpoint when this probe is the
238
- * one declaring a wedge.
239
- * @param {{ maybeCapture: (trigger: object) => boolean }} [options.witness]
240
- * @param {{ maybeRead: (reasonText: string) => boolean }} [options.usrsctpState]
241
- * @returns {{
242
- * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
243
- * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
244
- * noteEcho: (sessionId: string, echo: object) => void,
245
- * dispose: () => void
246
- * }}
247
- */
248
- export function createDeliveryProbe({
249
- log,
250
- intervalMs = PROBE_INTERVAL_MS,
251
- readDelivery,
252
- getTransportSnapshot,
253
- witness,
254
- usrsctpState
255
- }) {
256
- /** @type {Map<string, ProbeConnection>} */
257
- const connections = new Map();
258
-
259
- /**
260
- * Send this tick's probe on every channel of one connection, then report.
261
- *
262
- * @param {ProbeConnection} connection
263
- * @returns {void}
264
- */
265
- function tick(connection) {
266
- const now = Date.now();
267
- connection.seq += 1;
268
- connection.sentAt = now;
269
- const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
270
- for (const channel of connection.channels.keys()) {
271
- try {
272
- channel.sendMessage(message);
273
- } catch {
274
- // A channel closing between the check and the send is ordinary.
275
- }
276
- }
277
-
278
- // What this connection is getting away, and how far behind it therefore
279
- // sits. Both come from the send-queue watcher, which measures them anyway.
280
- const delivery = typeof readDelivery === "function" ? readDelivery(connection.id) : null;
281
- const bytesPerSecond = Number(delivery?.bytesPerSecond) || 0;
282
- const rttMs = Number(delivery?.rttMs) || 0;
283
- /** @type {Map<string, number | null>} */
284
- const allowed = new Map();
285
- for (const [channel, label] of connection.channels) {
286
- let queuedBytes = 0;
287
- try {
288
- queuedBytes = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
289
- } catch {
290
- queuedBytes = 0;
291
- }
292
- const allowance = allowedGap({
293
- queuedBytes,
294
- bytesPerSecond,
295
- rttMs,
296
- echoIntervalMs: connection.echoIntervalMs,
297
- intervalMs
298
- });
299
- // Several channels can carry one label only in malformed cases; the
300
- // larger allowance is the safer of the two.
301
- const held = allowed.get(label);
302
- if (allowance !== null && (!Number.isInteger(held) || allowance > held)) {
303
- allowed.set(label, allowance);
304
- } else if (!allowed.has(label)) {
305
- allowed.set(label, allowance);
306
- }
307
- }
308
- const widest = [...allowed.values()].reduce(
309
- (most, value) => (Number.isInteger(value) && value > most ? value : most),
310
- 0
311
- );
312
- const { verdict, detail } = readProbeState({
313
- seq: connection.seq,
314
- seen: connection.seen,
315
- labels: [...new Set(connection.channels.values())],
316
- echoes: connection.echoes,
317
- echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
318
- allowed,
319
- // Same arithmetic for the echo's own age: the peer cannot answer sooner
320
- // than its own cadence allows, and a hidden tab's is about a second.
321
- echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
322
- });
323
- if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
324
- connection.verdict = verdict;
325
- connection.reportedAt = now;
326
- log(`[dc-probe] ${connection.tag} ${verdict} — ${detail} at=${new Date(now).toISOString()}`);
327
- }
328
-
329
- // `association-stopped` alone is not certainty — see probeWedgeIsCertain.
330
- // A connection that is merely lagging by a bounded amount reaches this
331
- // verdict too (a hidden tab's own echo cadence, measured 2026-08-28), and
332
- // `seen` keeps advancing right along with it. Only a `seen` value that has
333
- // stopped moving ENTIRELY, for longer than this connection has ever shown
334
- // as a legitimate gap, is the wedge this exists to catch.
335
- const stuckForMs = connection.lastSeenAdvanceAt === 0 ? 0 : now - connection.lastSeenAdvanceAt;
336
- if (verdict === "association-stopped") {
337
- const { certain, needMs } = probeWedgeIsCertain({
338
- stuckForMs,
339
- longestHealthySeenGapMs: connection.longestHealthySeenGapMs
340
- });
341
- if (certain && !connection.probeCaptureStarted) {
342
- connection.probeCaptureStarted = true;
343
- const reasonText =
344
- `probe seen-counter unmoved ${Math.round(stuckForMs / 1000)}s against the ` +
345
- `${(needMs / 1000).toFixed(1)}s this connection's own history says is legitimate`;
346
- if (witness) {
347
- const snapshot = getTransportSnapshot?.(connection.id) ?? null;
348
- const started = witness.maybeCapture({
349
- sessionId: connection.id,
350
- tag: connection.tag,
351
- label: "probe",
352
- remote: snapshot?.remote ?? null,
353
- queuedBytes: 0,
354
- stuckForMs
355
- });
356
- if (!started) {
357
- connection.probeCaptureStarted = false;
358
- }
359
- }
360
- if (usrsctpState) {
361
- usrsctpState.maybeRead(reasonText);
362
- }
363
- }
364
- } else {
365
- // Not association-stopped any more: whatever was flagged has cleared,
366
- // and a later wedge on the same connection deserves its own attempt.
367
- connection.probeCaptureStarted = false;
368
- }
369
- }
370
-
371
- return {
372
- attach(sessionId, tag, label, channel) {
373
- let connection = connections.get(sessionId);
374
- if (!connection) {
375
- connection = {
376
- id: sessionId,
377
- tag,
378
- // The longest this peer has ever taken between two echoes. Its own
379
- // schedule, measured rather than assumed - a hidden tab answers
380
- // about once a second because the browser throttles the timer.
381
- echoIntervalMs: 0,
382
- channels: new Map(),
383
- seq: 0,
384
- sentAt: 0,
385
- seen: new Map(),
386
- echoAt: 0,
387
- echoes: 0,
388
- verdict: "",
389
- reportedAt: 0,
390
- lastSeenAdvanceAt: 0,
391
- longestHealthySeenGapMs: 0,
392
- probeCaptureStarted: false,
393
- timer: null
394
- };
395
- connections.set(sessionId, connection);
396
- }
397
- connection.channels.set(channel, label);
398
- if (connection.timer === null) {
399
- const held = connection;
400
- connection.timer = setInterval(() => tick(held), intervalMs);
401
- // The probe must never be the reason a process stays alive.
402
- if (typeof connection.timer.unref === "function") {
403
- connection.timer.unref();
404
- }
405
- }
406
- },
407
-
408
- detach(sessionId, channel) {
409
- const connection = connections.get(sessionId);
410
- if (!connection) {
411
- return;
412
- }
413
- connection.channels.delete(channel);
414
- if (connection.channels.size === 0) {
415
- if (connection.timer !== null) {
416
- clearInterval(connection.timer);
417
- connection.timer = null;
418
- }
419
- if (connections.get(sessionId) === connection) {
420
- connections.delete(sessionId);
421
- }
422
- }
423
- },
424
-
425
- noteEcho(sessionId, echo) {
426
- const connection = connections.get(sessionId);
427
- if (!connection || !echo || typeof echo !== "object") {
428
- return;
429
- }
430
- const now = Date.now();
431
- const seen = echo.seen;
432
- if (seen && typeof seen === "object") {
433
- let advanced = false;
434
- for (const [label, value] of Object.entries(seen)) {
435
- if (!Number.isInteger(value)) {
436
- continue;
437
- }
438
- const previous = connection.seen.get(label);
439
- if (!Number.isInteger(previous) || value > previous) {
440
- advanced = true;
441
- }
442
- connection.seen.set(label, value);
443
- }
444
- // What a wedge shows is this counter frozen, not merely behind — see
445
- // probeWedgeIsCertain. The gap since the last time ANY label moved is
446
- // this connection's own answer to "how long may a healthy report take
447
- // to arrive", recorded only while nothing is currently flagged (the
448
- // same guard `longestHealthyFlatMs` uses): a stretch already under
449
- // suspicion must not teach the detector to tolerate it.
450
- if (advanced) {
451
- if (connection.lastSeenAdvanceAt !== 0 && !connection.probeCaptureStarted) {
452
- const gap = now - connection.lastSeenAdvanceAt;
453
- if (gap > connection.longestHealthySeenGapMs) {
454
- connection.longestHealthySeenGapMs = gap;
455
- }
456
- }
457
- connection.lastSeenAdvanceAt = now;
458
- }
459
- }
460
- if (connection.echoAt !== 0) {
461
- const sinceLast = now - connection.echoAt;
462
- if (sinceLast > connection.echoIntervalMs) {
463
- connection.echoIntervalMs = sinceLast;
464
- }
465
- }
466
- connection.echoAt = now;
467
- connection.echoes += 1;
468
- },
469
-
470
- dispose() {
471
- for (const connection of connections.values()) {
472
- if (connection.timer !== null) {
473
- clearInterval(connection.timer);
474
- connection.timer = null;
475
- }
476
- }
477
- connections.clear();
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
+ }