@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 +11 -0
- package/bin/cli.js +2 -19
- package/package.json +2 -2
- package/routes/api/delivery-sink/get.js +68 -0
- package/server.js +7 -1
- package/services/data-channel-handler.js +33 -0
- package/services/delivery-probe.js +255 -0
- package/test/delivery-probe.test.js +78 -0
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.
|
|
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.
|
|
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
|
+
});
|