@torrent-tv/proxy 2.55.12 → 2.55.14

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 CHANGED
@@ -1,3 +1,14 @@
1
+ ## 2.55.14
2
+
3
+ - **Fix**: `utp-native` moves to 2.5.3-ttv.5, which carries ten defects found by reading the whole binding after the seventh crash of this family named its frame. Three callbacks read the connection pointer without checking it exists — and the socket carries none unless it was accepted or dialled, while its destructor announces itself regardless; the ttv.4 patch created one such path itself. The read callback copied at an accumulated offset without ever consulting the buffer length it maintains, so a peer sending more between two hand-offs wrote past the end — heap corruption does not fault where it happens, which is what six deaths inside libuv bookkeeping look like. A connection could be destroyed twice, deleting already-deleted napi references, and the socket pointer was never cleared. The callback macro checked none of the three napi results it then used. Plus IPv6, which the module never had at all, and the resolver that fed it addresses it could not use. Tests on the target: 77 checks, no failures — two of them were failing or hanging before.
4
+ - **Chore**: `--sctp-debug` is removed. It set node-datachannel's log level to verbose and filtered for `usrsctp:` lines, which only exist in a binary built with `SCTP_DEBUG=ON` — a source rebuild of libdatachannel that is not done any more. A flag that cannot do anything in any image we ship is an untruthful interface; the delivery probes of 2.55.13 answer the question it was wanted for (a probe on an unordered, no-retransmit channel passes head-of-line blocking in another stream but not a closed receive window) without touching the native build.
5
+
6
+ ## 2.55.13
7
+
8
+ - **New**: numbered delivery probes (`services/delivery-probe.js`). Every channel of a connection carries a numbered probe twice a second; the far end echoes back the highest number it saw on each, and the gaps are read into a verdict — `flowing`, `stream-stuck` (a retransmission held up in one stream), `association-stopped` (the window shut or transmission halted), `reverse-direction-gone`. The proxy's own counters could never separate those: libdatachannel's `bytesSent` counts bytes accepted into usrsctp, not bytes put on the wire. Logged as `[dc-probe]`, with the numbers that produced each verdict beside it.
9
+ - **New**: the far end's own account of itself is logged as `[dc-far]` — tab visibility, event-loop lag, the longest the channel's message handler ran, the transport's received bytes and per-channel message counts. A page that has stopped draining the channel and a sender that has stopped transmitting look identical from here; this is the difference.
10
+ - **New**: `--delivery-sink` serves `GET /api/delivery-sink?bytes=N`, a torrent-free stream of a repeating pattern, so gigabytes can be pushed through a data channel on demand. Off without the flag (404), and it exists because the delivery freeze takes hundreds of megabytes and the real transport to appear at all.
11
+
1
12
  ## 2.55.12
2
13
 
3
14
  - **Fix**: `--sctp-debug` now passes the required callback to `initLogger` (`node-datachannel` 0.32.x needs two args). The 2.55.10 single-arg call always threw `Function expected` and left verbose logging off.
package/bin/cli.js CHANGED
@@ -80,6 +80,7 @@ program
80
80
  .option("--name <name>", "Display name")
81
81
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
82
82
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
83
+ .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
83
84
  .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
84
85
  .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
85
86
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
@@ -97,7 +98,6 @@ program
97
98
  DEFAULT_SEGMENT_FORMAT_ID
98
99
  )
99
100
  .option("--token <token>", "Registration token", "")
100
- .option("--sctp-debug", "Enable verbose SCTP debug logging (SCTP_DEBUG build only)")
101
101
  .addHelpText("after", HELP_EXAMPLES);
102
102
 
103
103
  program.parse(process.argv);
@@ -283,24 +283,6 @@ async function shutdown(signal) {
283
283
 
284
284
  try {
285
285
  logToFile(options.logFile);
286
- // Diagnostic: verbose SCTP logging. The binary must have been built with
287
- // SCTP_DEBUG=ON (addon 0.48.0); this flag merely sets the log level.
288
- if (options.sctpDebug) {
289
- try {
290
- const mod = await import("node-datachannel");
291
- const target = mod.default ?? mod;
292
- if (typeof target.initLogger === "function") {
293
- target.initLogger("Verbose", (level, message) => {
294
- if (typeof message === "string" && message.startsWith("usrsctp:")) {
295
- logger.info(message);
296
- }
297
- });
298
- logger.info("SCTP debug verbose logging enabled");
299
- }
300
- } catch (error) {
301
- logger.warn(`Failed to enable SCTP debug: ${error?.message ?? error}`);
302
- }
303
- }
304
286
  if (transcodeAudio) {
305
287
  assertFfmpegAvailability();
306
288
  }
@@ -313,6 +295,7 @@ try {
313
295
  memoryBytes,
314
296
  segmentFormat: options.segmentFormat,
315
297
  stateDir: options.stateDir,
298
+ deliverySink: options.deliverySink === true,
316
299
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
317
300
  // built inside `startProxyServer`, before `dataChannelHandler` — the
318
301
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.55.12",
3
+ "version": "2.55.14",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -45,6 +45,6 @@
45
45
  "@biomejs/biome": "^2.5.7"
46
46
  },
47
47
  "overrides": {
48
- "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.4"
48
+ "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.5"
49
49
  }
50
50
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A stream of bytes with no torrent behind it, for measuring delivery alone.
3
+ *
4
+ * GET /api/delivery-sink?bytes=8388608
5
+ *
6
+ * The delivery freeze (roadmap item 11) takes hundreds of megabytes to appear
7
+ * and needs the real transport to appear at all, so reproducing it means
8
+ * pushing gigabytes through a data channel on demand. Doing that through a real
9
+ * file drags in the swarm, the encoder and the disk, none of which is under
10
+ * test and each of which has its own stalls; this route removes them and leaves
11
+ * the path the question is about: the same handler, the same chunk loop, the
12
+ * same backpressure, the same channel.
13
+ *
14
+ * The body is a repeating pattern rather than zeroes, so a receiver can verify
15
+ * it got the bytes it was promised in the order they were sent — a silent
16
+ * truncation would otherwise read as a successful transfer.
17
+ *
18
+ * Enabled only when the proxy was started with `--delivery-sink`; without the
19
+ * flag the route answers 404, so an ordinary install has no such endpoint.
20
+ *
21
+ * @param {import("fastify").FastifyRequest} req
22
+ * @param {import("fastify").FastifyReply} reply
23
+ * @param {{ enabled: boolean }} deps
24
+ * @returns {Promise<void>}
25
+ */
26
+ export async function handleApiDeliverySinkGet(req, reply, { enabled } = {}) {
27
+ if (enabled !== true) {
28
+ return reply.code(404).send({ error: "Not found." });
29
+ }
30
+
31
+ const requested = Number((req.query ?? {}).bytes);
32
+ const total = Number.isFinite(requested)
33
+ ? Math.min(Math.max(Math.trunc(requested), 0), MAX_SINK_BYTES)
34
+ : DEFAULT_SINK_BYTES;
35
+
36
+ // One buffer, reused: allocating a fresh chunk per iteration would make this
37
+ // route measure the allocator as much as the transport.
38
+ const chunk = Buffer.alloc(CHUNK_BYTES);
39
+ for (let i = 0; i < CHUNK_BYTES; i += 1) {
40
+ chunk[i] = i % 251; // 251 is prime, so the pattern does not align to any power of two.
41
+ }
42
+
43
+ reply.header("content-type", "application/octet-stream");
44
+ reply.header("content-length", String(total));
45
+ reply.header("cache-control", "no-store");
46
+
47
+ let sent = 0;
48
+ const stream = new ReadableStream({
49
+ pull(controller) {
50
+ if (sent >= total) {
51
+ controller.close();
52
+ return;
53
+ }
54
+ const size = Math.min(CHUNK_BYTES, total - sent);
55
+ controller.enqueue(size === CHUNK_BYTES ? chunk : chunk.subarray(0, size));
56
+ sent += size;
57
+ }
58
+ });
59
+
60
+ return reply.send(stream);
61
+ }
62
+
63
+ /** Chunk size, matching what a body read hands the send loop for a real file. */
64
+ const CHUNK_BYTES = 64 * 1024;
65
+ /** Default when no size is asked for: about one segment of 1080p. */
66
+ const DEFAULT_SINK_BYTES = 8 * 1024 * 1024;
67
+ /** Ceiling, so one request cannot ask the proxy for an unbounded stream. */
68
+ const MAX_SINK_BYTES = 64 * 1024 * 1024;
package/server.js CHANGED
@@ -16,6 +16,7 @@ import { createRequire } from "node:module";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { handleHealthGet } from "./routes/health/get.js";
18
18
  import { handleHealthzGet } from "./routes/healthz/get.js";
19
+ import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
19
20
  import { handleApiSourcesPost } from "./routes/api/sources/post.js";
20
21
  import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
22
  import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
@@ -81,7 +82,8 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
81
82
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
82
83
  */
83
84
  export async function startProxyServer({
84
- host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues
85
+ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
86
+ deliverySink = false
85
87
  }) {
86
88
  const app = Fastify({
87
89
  // No practical body-size limit — the proxy server is localhost-only and
@@ -209,6 +211,10 @@ export async function startProxyServer({
209
211
 
210
212
  app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
211
213
  app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
214
+ // Off unless --delivery-sink was given; see the route for why it exists.
215
+ app.get("/api/delivery-sink", async (req, reply) =>
216
+ handleApiDeliverySinkGet(req, reply, { enabled: deliverySink === true })
217
+ );
212
218
  app.post("/api/sources", async (req, reply) =>
213
219
  handleApiSourcesPost(req, reply, { sourceRegistry })
214
220
  );
@@ -12,10 +12,12 @@
12
12
  * ```
13
13
  * { type: "request", requestId, method, path, query, headers, body }
14
14
  * { type: "ping", id }
15
+ * { type: "probe-echo", seen: { <label>: seq }, report }
15
16
  * ```
16
17
  *
17
18
  * Proxy → Browser
18
19
  * ```
20
+ * { type: "probe", seq, sentAt } (JSON string)
19
21
  * { type: "response-start", requestId, status, headers } (JSON string)
20
22
  * { type: "response-error", requestId, error: string } (JSON string)
21
23
  * { type: "pong", id } (JSON string)
@@ -44,6 +46,7 @@
44
46
  /** @import { DataChannel } from 'node-datachannel' */
45
47
 
46
48
  import { deriveSourceKey } from "./torrent-source-key.js";
49
+ import { createDeliveryProbe } from "./delivery-probe.js";
47
50
 
48
51
  /**
49
52
  * Configuration for the data channel handler.
@@ -417,6 +420,11 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
417
420
  const requestIdCache = new Map();
418
421
 
419
422
  const watchSendQueue = makeSendQueueWatcher({ log: (message) => log(message), getTransportSnapshot, witness });
423
+ // Numbered probes on every channel, and the browser's echo of what it saw.
424
+ // The proxy's own counters cannot say whether bytes it handed to usrsctp were
425
+ // ever put on the wire; the far end can, and it keeps answering throughout a
426
+ // freeze. See services/delivery-probe.js.
427
+ const deliveryProbe = createDeliveryProbe({ log: (message) => log(message) });
420
428
 
421
429
  /**
422
430
  * @param {string} message
@@ -440,6 +448,7 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
440
448
  const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
441
449
  log(`[dc] Session ${tag}: channel open`);
442
450
  const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
451
+ deliveryProbe.attach(sessionId, tag, label, channel);
443
452
 
444
453
  // Partial chunked-request bodies in flight on THIS channel, keyed by
445
454
  // requestId. Each entry buffers frames until the done frame, then runs the
@@ -568,11 +577,35 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
568
577
 
569
578
  if (message.type === "ping") {
570
579
  send(channel, { type: "pong", id: message.id });
580
+ return;
581
+ }
582
+
583
+ // The far end's answer to the numbered probes, plus what it can see of
584
+ // its own receiving. It travels browser to proxy, the direction that goes
585
+ // on working through a freeze, so it arrives when nothing else does.
586
+ if (message.type === "probe-echo") {
587
+ deliveryProbe.noteEcho(sessionId, message);
588
+ if (message.report && typeof message.report === "object") {
589
+ const report = message.report;
590
+ const channels = report.channels && typeof report.channels === "object"
591
+ ? Object.entries(report.channels)
592
+ .map(([name, counters]) => `${name}=${counters?.messages ?? "?"}msg/${counters?.bytes ?? "?"}B`)
593
+ .join(" ")
594
+ : "";
595
+ log(
596
+ `[dc-far] ${tag} visibility=${report.visibility ?? "?"} ` +
597
+ `loopLag=${report.loopLagMs ?? "?"}ms handler=${report.handlerMaxMs ?? "?"}ms ` +
598
+ `transportIn=${report.transportBytesReceived ?? "?"} ${channels} ` +
599
+ `pending=${report.pending ?? "?"} at=${new Date().toISOString()}`
600
+ );
601
+ }
602
+ return;
571
603
  }
572
604
  });
573
605
 
574
606
  channel.onClosed(() => {
575
607
  stopWatchdog();
608
+ deliveryProbe.detach(sessionId, channel);
576
609
  for (const entry of partials.values()) {
577
610
  clearTimeout(entry.timer);
578
611
  }
@@ -0,0 +1,255 @@
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 the verdict takes {@link MISSES_FOR_VERDICT}
39
+ * consecutive probes.
40
+ */
41
+ export const PROBE_INTERVAL_MS = 500;
42
+
43
+ /**
44
+ * How many probes may be outstanding on a channel before it counts as behind.
45
+ *
46
+ * A segment of 6-11 MB leaves in well under a second when the link is healthy,
47
+ * but it shares the association with the probe, so one or two probes can
48
+ * legitimately sit behind it. Four is two seconds - longer than any measured
49
+ * healthy burst, shorter than the five seconds the old heartbeat needed to say
50
+ * anything at all.
51
+ */
52
+ export const MISSES_FOR_VERDICT = 4;
53
+
54
+ /** The label the browser gives the unordered, non-retransmitting channel. */
55
+ export const UNRELIABLE_LABEL = "proxy-fast";
56
+
57
+ /** An echo older than this means the reverse direction has stopped too. */
58
+ const ECHO_STALE_MS = 5_000;
59
+
60
+ /** How often the probe state is written to the log while nothing changes. */
61
+ const REPORT_INTERVAL_MS = 5_000;
62
+
63
+ /**
64
+ * One connection's probe state.
65
+ *
66
+ * @typedef {Object} ProbeConnection
67
+ * @property {string} tag
68
+ * @property {Map<import('node-datachannel').DataChannel, string>} channels
69
+ * @property {number} seq - Highest probe number sent.
70
+ * @property {number} sentAt - When that probe was sent.
71
+ * @property {Map<string, number>} seen - Label to the highest number the browser reported.
72
+ * @property {number} echoAt - When the last echo arrived (0 = never).
73
+ * @property {number} echoes - How many echoes have arrived.
74
+ * @property {string} verdict - Last verdict reported, so a change is logged at once.
75
+ * @property {number} reportedAt - When the state was last written to the log.
76
+ * @property {ReturnType<typeof setInterval> | null} timer
77
+ */
78
+
79
+ /**
80
+ * Read the gaps and say what they mean.
81
+ *
82
+ * Exported so the rule is testable without a connection: the same numbers
83
+ * always produce the same word.
84
+ *
85
+ * @param {{ seq: number, seen: Map<string, number> | Record<string, number>, labels: string[], echoes: number, echoAgeMs: number | null }} state
86
+ * @returns {{ verdict: string, detail: string }}
87
+ */
88
+ export function readProbeState(state) {
89
+ const seenOf = (label) =>
90
+ state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
91
+ const parts = [];
92
+ let orderedBehind = false;
93
+ let unreliableBehind = false;
94
+ let unreliableKnown = false;
95
+ for (const label of state.labels) {
96
+ const seen = seenOf(label);
97
+ const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
98
+ parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"})`);
99
+ const behind = gap === null || gap >= MISSES_FOR_VERDICT;
100
+ if (label === UNRELIABLE_LABEL) {
101
+ unreliableKnown = true;
102
+ unreliableBehind = behind;
103
+ } else if (behind) {
104
+ orderedBehind = true;
105
+ }
106
+ }
107
+ const detail =
108
+ `sent=${state.seq} ${parts.join(" ")} ` +
109
+ `echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}`;
110
+
111
+ if (state.echoes === 0) {
112
+ return { verdict: "no-echo-yet", detail };
113
+ }
114
+ if (state.echoAgeMs !== null && state.echoAgeMs > ECHO_STALE_MS) {
115
+ return { verdict: "reverse-direction-gone", detail };
116
+ }
117
+ if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
118
+ return { verdict: "flowing", detail };
119
+ }
120
+ if (orderedBehind && unreliableKnown && !unreliableBehind) {
121
+ return { verdict: "stream-stuck", detail };
122
+ }
123
+ if (orderedBehind) {
124
+ return {
125
+ verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
126
+ detail
127
+ };
128
+ }
129
+ return { verdict: "unreliable-behind-only", detail };
130
+ }
131
+
132
+ /**
133
+ * Create the probe service. One instance serves every session.
134
+ *
135
+ * @param {Object} options
136
+ * @param {(message: string) => void} options.log
137
+ * @param {number} [options.intervalMs]
138
+ * @returns {{
139
+ * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
140
+ * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
141
+ * noteEcho: (sessionId: string, echo: object) => void,
142
+ * dispose: () => void
143
+ * }}
144
+ */
145
+ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS }) {
146
+ /** @type {Map<string, ProbeConnection>} */
147
+ const connections = new Map();
148
+
149
+ /**
150
+ * Send this tick's probe on every channel of one connection, then report.
151
+ *
152
+ * @param {ProbeConnection} connection
153
+ * @returns {void}
154
+ */
155
+ function tick(connection) {
156
+ const now = Date.now();
157
+ connection.seq += 1;
158
+ connection.sentAt = now;
159
+ const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
160
+ for (const channel of connection.channels.keys()) {
161
+ try {
162
+ channel.sendMessage(message);
163
+ } catch {
164
+ // A channel closing between the check and the send is ordinary.
165
+ }
166
+ }
167
+
168
+ const { verdict, detail } = readProbeState({
169
+ seq: connection.seq,
170
+ seen: connection.seen,
171
+ labels: [...new Set(connection.channels.values())],
172
+ echoes: connection.echoes,
173
+ echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt
174
+ });
175
+ if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
176
+ connection.verdict = verdict;
177
+ connection.reportedAt = now;
178
+ log(`[dc-probe] ${connection.tag} ${verdict} — ${detail} at=${new Date(now).toISOString()}`);
179
+ }
180
+ }
181
+
182
+ return {
183
+ attach(sessionId, tag, label, channel) {
184
+ let connection = connections.get(sessionId);
185
+ if (!connection) {
186
+ connection = {
187
+ tag,
188
+ channels: new Map(),
189
+ seq: 0,
190
+ sentAt: 0,
191
+ seen: new Map(),
192
+ echoAt: 0,
193
+ echoes: 0,
194
+ verdict: "",
195
+ reportedAt: 0,
196
+ timer: null
197
+ };
198
+ connections.set(sessionId, connection);
199
+ }
200
+ connection.channels.set(channel, label);
201
+ if (connection.timer === null) {
202
+ const held = connection;
203
+ connection.timer = setInterval(() => tick(held), intervalMs);
204
+ // The probe must never be the reason a process stays alive.
205
+ if (typeof connection.timer.unref === "function") {
206
+ connection.timer.unref();
207
+ }
208
+ }
209
+ },
210
+
211
+ detach(sessionId, channel) {
212
+ const connection = connections.get(sessionId);
213
+ if (!connection) {
214
+ return;
215
+ }
216
+ connection.channels.delete(channel);
217
+ if (connection.channels.size === 0) {
218
+ if (connection.timer !== null) {
219
+ clearInterval(connection.timer);
220
+ connection.timer = null;
221
+ }
222
+ if (connections.get(sessionId) === connection) {
223
+ connections.delete(sessionId);
224
+ }
225
+ }
226
+ },
227
+
228
+ noteEcho(sessionId, echo) {
229
+ const connection = connections.get(sessionId);
230
+ if (!connection || !echo || typeof echo !== "object") {
231
+ return;
232
+ }
233
+ const seen = echo.seen;
234
+ if (seen && typeof seen === "object") {
235
+ for (const [label, value] of Object.entries(seen)) {
236
+ if (Number.isInteger(value)) {
237
+ connection.seen.set(label, value);
238
+ }
239
+ }
240
+ }
241
+ connection.echoAt = Date.now();
242
+ connection.echoes += 1;
243
+ },
244
+
245
+ dispose() {
246
+ for (const connection of connections.values()) {
247
+ if (connection.timer !== null) {
248
+ clearInterval(connection.timer);
249
+ connection.timer = null;
250
+ }
251
+ }
252
+ connections.clear();
253
+ }
254
+ };
255
+ }
@@ -0,0 +1,78 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { readProbeState, MISSES_FOR_VERDICT, UNRELIABLE_LABEL } from "../services/delivery-probe.js";
5
+
6
+ const ORDERED = ["proxy", "proxy-control"];
7
+ const ALL = [...ORDERED, UNRELIABLE_LABEL];
8
+
9
+ /**
10
+ * @param {Record<string, number>} seen
11
+ * @param {object} [overrides]
12
+ */
13
+ function state(seen, overrides = {}) {
14
+ return {
15
+ seq: 100,
16
+ seen,
17
+ labels: ALL,
18
+ echoes: 5,
19
+ echoAgeMs: 400,
20
+ ...overrides
21
+ };
22
+ }
23
+
24
+ test("every channel current reads as flowing", () => {
25
+ const { verdict } = readProbeState(state({ proxy: 100, "proxy-control": 99, "proxy-fast": 100 }));
26
+ assert.equal(verdict, "flowing");
27
+ });
28
+
29
+ test("a lag shorter than the verdict window is still flowing", () => {
30
+ const behind = 100 - (MISSES_FOR_VERDICT - 1);
31
+ const { verdict } = readProbeState(
32
+ state({ proxy: behind, "proxy-control": behind, "proxy-fast": 100 })
33
+ );
34
+ assert.equal(verdict, "flowing");
35
+ });
36
+
37
+ test("ordered channels behind while the unordered one keeps up names a stuck stream", () => {
38
+ const { verdict, detail } = readProbeState(
39
+ state({ proxy: 40, "proxy-control": 41, "proxy-fast": 100 })
40
+ );
41
+ assert.equal(verdict, "stream-stuck");
42
+ // The numbers that produced the verdict must be in the line beside it.
43
+ assert.match(detail, /proxy=40\(gap 60\)/);
44
+ });
45
+
46
+ test("every channel behind names the association", () => {
47
+ const { verdict } = readProbeState(
48
+ state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 })
49
+ );
50
+ assert.equal(verdict, "association-stopped");
51
+ });
52
+
53
+ test("without the unordered channel the verdict says it cannot compare", () => {
54
+ const { verdict } = readProbeState(
55
+ state({ proxy: 40, "proxy-control": 41 }, { labels: ORDERED })
56
+ );
57
+ assert.equal(verdict, "ordered-behind-no-comparison");
58
+ });
59
+
60
+ test("a stale echo means the reverse direction went too", () => {
61
+ const { verdict } = readProbeState(
62
+ state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { echoAgeMs: 30_000 })
63
+ );
64
+ assert.equal(verdict, "reverse-direction-gone");
65
+ });
66
+
67
+ test("before the first echo nothing is claimed", () => {
68
+ const { verdict } = readProbeState(state({}, { echoes: 0, echoAgeMs: null }));
69
+ assert.equal(verdict, "no-echo-yet");
70
+ });
71
+
72
+ test("a channel that has never reported counts as behind, not as unknown", () => {
73
+ const { verdict, detail } = readProbeState(
74
+ state({ "proxy-fast": 100 })
75
+ );
76
+ assert.equal(verdict, "stream-stuck");
77
+ assert.match(detail, /proxy=\?\(gap \?\)/);
78
+ });