@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.
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +17 -0
- package/bin/cli.js +520 -512
- package/docs/container-architecture.md +86 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +5 -6
- package/routes/api/subtitles/get.js +39 -208
- package/services/container/AviContainer.js +45 -0
- package/services/container/Container.js +59 -0
- package/services/container/ContainerFactory.js +31 -0
- package/services/container/MatroskaContainer.js +289 -0
- package/services/container/Mp4Container.js +242 -0
- package/services/container/index.js +5 -0
- package/services/controllers/PlaybackController.js +33 -0
- package/services/controllers/SubtitleController.js +126 -0
- package/services/controllers/index.js +2 -0
- package/services/delivery-probe.js +532 -480
- package/services/memory-report.js +120 -15
- package/services/orchestrators/ContainerOrchestrator.js +89 -0
- package/services/orchestrators/SubtitleOrchestrator.js +101 -0
- package/services/orchestrators/index.js +2 -0
- package/services/piece-store/shared-piece-store.js +870 -791
- package/services/torrent-worker/worker.js +738 -706
- package/services/tracks/AudioTrack.js +40 -0
- package/services/tracks/ContainerTrack.js +72 -0
- package/services/tracks/ExternalSubtitleFile.js +27 -0
- package/services/tracks/ImageSubtitleTrack.js +19 -0
- package/services/tracks/SubtitleTrack.js +38 -0
- package/services/tracks/TextSubtitleTrack.js +29 -0
- package/services/tracks/VideoTrack.js +33 -0
- package/services/tracks/index.js +7 -0
- package/test/delivery-probe.test.js +213 -158
- package/test/memory-budget.test.js +89 -2
- package/test/worker-source-race.test.js +0 -76
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Audio track — extends ContainerTrack with audio-specific flags.
|
|
3
|
+
*
|
|
4
|
+
* Matroska RFC 9559 §5.1.4.1:
|
|
5
|
+
* - FlagOriginal 0x55AE (default 0) — track is original language.
|
|
6
|
+
* - FlagCommentary 0x55AF — commentary track.
|
|
7
|
+
* - FlagVisualImpaired 0x55AC — audio description for visually impaired.
|
|
8
|
+
* - FlagTextDescriptions 0x55AD, FlagHearingImpaired etc. may also appear
|
|
9
|
+
* but the three above drive the audio menu (research/container-spec-conformance).
|
|
10
|
+
* MP4 ISO/IEC 14496-12: tkhd alternate_group groups alternate audio tracks.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ContainerTrack } from "./ContainerTrack.js";
|
|
14
|
+
|
|
15
|
+
export class AudioTrack extends ContainerTrack {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params - See ContainerTrack plus:
|
|
18
|
+
* @param {boolean} params.isOriginal - FlagOriginal
|
|
19
|
+
* @param {boolean} params.isCommentary - FlagCommentary
|
|
20
|
+
* @param {boolean} params.isVisualImpaired - FlagVisualImpaired / descriptive audio
|
|
21
|
+
* @param {number | null} params.channels
|
|
22
|
+
* @param {number | null} params.samplingFrequency
|
|
23
|
+
*/
|
|
24
|
+
constructor(params) {
|
|
25
|
+
super({ ...params, type: "audio" });
|
|
26
|
+
this.isOriginal = params.isOriginal === true;
|
|
27
|
+
this.isCommentary = params.isCommentary === true;
|
|
28
|
+
this.isVisualImpaired = params.isVisualImpaired === true;
|
|
29
|
+
this.channels = Number.isFinite(params.channels) ? params.channels : null;
|
|
30
|
+
this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Human label helper — commentary and descriptive audio must not look like main track. */
|
|
34
|
+
audioRoleLabel() {
|
|
35
|
+
if (this.isCommentary) return "commentary";
|
|
36
|
+
if (this.isVisualImpaired) return "descriptive";
|
|
37
|
+
if (this.isOriginal) return "original";
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Base container track — what the container declares about one track.
|
|
3
|
+
*
|
|
4
|
+
* Spec sources:
|
|
5
|
+
* - Matroska RFC 9559 §5.1.4.1 TrackEntry: TrackNumber(0xD7), TrackType(0x83),
|
|
6
|
+
* FlagEnabled(0xB9, default 1), FlagDefault(0x88, default 1),
|
|
7
|
+
* Language(0x22B59C, default "eng"), LanguageBCP47(0x22B59D, MUST ignore
|
|
8
|
+
* Language when present), Name(0x536E), CodecID(0x86), CodecPrivate(0x63A2).
|
|
9
|
+
* - MP4 ISO/IEC 14496-12 §8.3.2 tkhd (track_ID, flags track_enabled 0x000001,
|
|
10
|
+
* alternate_group), §8.4.2 mdhd (language, timescale), §8.4.5 hdlr
|
|
11
|
+
* (handler_type), §8.4.6 elng (extended language).
|
|
12
|
+
*
|
|
13
|
+
* Only fields that exist for EVERY track type live here. Type-specific flags
|
|
14
|
+
* (FlagForced, FlagHearingImpaired, FlagVisualImpaired, FlagOriginal,
|
|
15
|
+
* FlagCommentary) belong to subclasses — RFC 9559 states FlagForced "Applies
|
|
16
|
+
* only to subtitles", so VideoTrack must not carry it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export class ContainerTrack {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {number} params.trackNumber - Matroska TrackNumber or MP4 track_ID.
|
|
23
|
+
* @param {number} params.declaredIndex - Position among ALL subtitle/audio/video tracks as container orders them; equals ffmpeg 0:s:N / 0:a:N for that type. Stable across image/text filtering.
|
|
24
|
+
* @param {string} params.type - "video" | "audio" | "subtitle" | "other"
|
|
25
|
+
* @param {string} params.codecId - Matroska CodecID or MP4 sample entry type (e.g. "S_TEXT/UTF8", "avc1", "mp4a").
|
|
26
|
+
* @param {string} params.language - Three-letter code or packed mdhd code; empty when absent. For Matroska, when LanguageBCP47 is present this is the BCP47 value ignored per MUST — caller stores both, but `language` here is the resolved one.
|
|
27
|
+
* @param {string} params.languageBcp47 - RFC 5646 tag from LanguageBCP47 / elng, or "".
|
|
28
|
+
* @param {string} params.name - Track Name / title, or "".
|
|
29
|
+
* @param {boolean} params.isEnabled - FlagEnabled / tkhd track_enabled. Default true per both specs. Matroska zero-length element means default, not disabled.
|
|
30
|
+
* @param {boolean} params.isDefault - FlagDefault / tkhd? For MP4, derived from handler default? For Matroska, after applying default 1.
|
|
31
|
+
* @param {boolean} params.declaresDefault - Whether FlagDefault was explicitly written (Matroska) or inferred. Needed because ffmpeg banner cannot distinguish "every track marked" from "no track marked".
|
|
32
|
+
* @param {string} [params.codecPrivateB64] - Base64 of CodecPrivate (ASS header etc.), or "".
|
|
33
|
+
* @param {number} [params.alternateGroup] - MP4 alternate_group (0 = no group).
|
|
34
|
+
*/
|
|
35
|
+
constructor({
|
|
36
|
+
trackNumber,
|
|
37
|
+
declaredIndex,
|
|
38
|
+
type,
|
|
39
|
+
codecId,
|
|
40
|
+
language,
|
|
41
|
+
languageBcp47,
|
|
42
|
+
name,
|
|
43
|
+
isEnabled,
|
|
44
|
+
isDefault,
|
|
45
|
+
declaresDefault,
|
|
46
|
+
codecPrivateB64 = "",
|
|
47
|
+
alternateGroup = 0
|
|
48
|
+
}) {
|
|
49
|
+
this.trackNumber = trackNumber;
|
|
50
|
+
this.declaredIndex = declaredIndex;
|
|
51
|
+
this.type = type;
|
|
52
|
+
this.codecId = codecId ?? "";
|
|
53
|
+
this.language = language ?? "";
|
|
54
|
+
this.languageBcp47 = languageBcp47 ?? "";
|
|
55
|
+
this.name = name ?? "";
|
|
56
|
+
this.isEnabled = isEnabled !== false;
|
|
57
|
+
this.isDefault = isDefault === true;
|
|
58
|
+
this.declaresDefault = declaresDefault === true;
|
|
59
|
+
this.codecPrivateB64 = codecPrivateB64 ?? "";
|
|
60
|
+
this.alternateGroup = Number.isFinite(alternateGroup) ? alternateGroup : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether this track should be offered in UI menus. Base rule: disabled tracks hidden but still counted for declaredIndex. */
|
|
64
|
+
isOfferable() {
|
|
65
|
+
return this.isEnabled;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** RFC 5646 tag wins over three-letter code when present. */
|
|
69
|
+
resolvedLanguage() {
|
|
70
|
+
return this.languageBcp47 || this.language;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file External subtitle file — not part of the media container, but shares TextSubtitleTrack API.
|
|
3
|
+
*
|
|
4
|
+
* A standalone .srt/.ass/.ssa/.vtt file beside the video in the same torrent.
|
|
5
|
+
* Has no container flags; language comes from filename suffix or franc detection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class ExternalSubtitleFile {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} params
|
|
11
|
+
* @param {string} params.fileName
|
|
12
|
+
* @param {number} params.fileIndex - Torrent file index.
|
|
13
|
+
* @param {string} params.extension - ".srt" etc. lowercased.
|
|
14
|
+
* @param {string} params.language - Hint from filename or "".
|
|
15
|
+
*/
|
|
16
|
+
constructor({ fileName, fileIndex, extension, language = "" }) {
|
|
17
|
+
this.fileName = fileName;
|
|
18
|
+
this.fileIndex = fileIndex;
|
|
19
|
+
this.extension = extension;
|
|
20
|
+
this.language = language;
|
|
21
|
+
this.type = "external-subtitle";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isTextBased() {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Image subtitle track — PGS / VobSub / subp / clcp. Not convertible to WebVTT.
|
|
3
|
+
*
|
|
4
|
+
* Matroska: S_HDMV/PGS, S_VOBSUB, S_IMAGE/BMP etc.
|
|
5
|
+
* MP4: stpp is TTML (XML, excluded from Text), subp (VobSub), clcp (closed captions)
|
|
6
|
+
* Kept in the model only to preserve declaredIndex alignment with ffmpeg 0:s:N.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SubtitleTrack } from "./SubtitleTrack.js";
|
|
10
|
+
|
|
11
|
+
export class ImageSubtitleTrack extends SubtitleTrack {
|
|
12
|
+
constructor(params) {
|
|
13
|
+
super(params);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
isTextBased() {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle track — extends ContainerTrack with subtitle-only flags.
|
|
3
|
+
*
|
|
4
|
+
* Matroska RFC 9559:
|
|
5
|
+
* - FlagForced 0x55AA default 0, applies ONLY to subtitles.
|
|
6
|
+
* - FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC (SDH etc.)
|
|
7
|
+
* - FlagTextDescriptions 0x55AD also subtitle-related.
|
|
8
|
+
* - FlagEnabled 0xB9 still handled in base (disabled tracks counted but not offered).
|
|
9
|
+
* MP4 ISO 14496-12 + Apple tx3g extension:
|
|
10
|
+
* - handler sbtl/subt/text/wvtt vs subp/clcp (image)
|
|
11
|
+
* - tx3g displayFlags forced bits 0x40000000 / 0x80000000 (unconfirmed primary source, carried as raw flags)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ContainerTrack } from "./ContainerTrack.js";
|
|
15
|
+
|
|
16
|
+
export class SubtitleTrack extends ContainerTrack {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} params - See ContainerTrack plus:
|
|
19
|
+
* @param {boolean} params.isForced - FlagForced (matroska) or tx3g forced display flag.
|
|
20
|
+
* @param {boolean} params.isHearingImpaired - FlagHearingImpaired
|
|
21
|
+
* @param {boolean} params.isVisualImpaired - FlagVisualImpaired
|
|
22
|
+
* @param {number[]} params.clusterPositions - Matroska: file offsets of clusters whose CuePoints name this track (for push). Empty when indexless.
|
|
23
|
+
* @param {Array<{offset:number,size:number,startSeconds:number,endSeconds:number}>} params.samples - MP4: per-cue byte ranges from sample tables.
|
|
24
|
+
*/
|
|
25
|
+
constructor(params) {
|
|
26
|
+
super({ ...params, type: "subtitle" });
|
|
27
|
+
this.isForced = params.isForced === true;
|
|
28
|
+
this.isHearingImpaired = params.isHearingImpaired === true;
|
|
29
|
+
this.isVisualImpaired = params.isVisualImpaired === true;
|
|
30
|
+
this.clusterPositions = Array.isArray(params.clusterPositions) ? params.clusterPositions : [];
|
|
31
|
+
this.samples = Array.isArray(params.samples) ? params.samples : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether this track can be converted to WebVTT in current pipeline. Overridden by subclasses. */
|
|
35
|
+
isTextBased() {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Text subtitle track — convertible to WebVTT.
|
|
3
|
+
*
|
|
4
|
+
* Matroska: S_TEXT/UTF8, S_TEXT/ASS, S_TEXT/SSA
|
|
5
|
+
* MP4: tx3g, text, wvtt (stpp/TTML is NOT text for this pipeline — excluded)
|
|
6
|
+
* External files: .srt .ass .ssa .vtt — modelled as TextSubtitleTrack with no container backing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SubtitleTrack } from "./SubtitleTrack.js";
|
|
10
|
+
|
|
11
|
+
const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
|
|
12
|
+
const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
|
|
13
|
+
|
|
14
|
+
export class TextSubtitleTrack extends SubtitleTrack {
|
|
15
|
+
constructor(params) {
|
|
16
|
+
super(params);
|
|
17
|
+
this.textCodec = params.codecId ?? "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isTextBased() {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static isTextCodec(codecId) {
|
|
25
|
+
return TEXT_CODECS_MATROSKA.has(codecId) || TEXT_FORMATS_MP4.has(codecId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Video track — extends ContainerTrack with video-specific container fields.
|
|
3
|
+
*
|
|
4
|
+
* Matroska RFC 9559: TrackEntry/Video (PixelWidth/Height, DisplayWidth/Height)
|
|
5
|
+
* MP4 ISO/IEC 14496-12: tkhd width/height, mdhd timescale, stsd sample entry
|
|
6
|
+
* (avc1/hev1/etc.), stss/stts for keyframes, colr for HDR.
|
|
7
|
+
* No FlagForced / FlagHearingImpaired — those are subtitle-only per spec.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ContainerTrack } from "./ContainerTrack.js";
|
|
11
|
+
|
|
12
|
+
export class VideoTrack extends ContainerTrack {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} params - See ContainerTrack plus:
|
|
15
|
+
* @param {number | null} params.width - Coded width (PixelWidth / tkhd width).
|
|
16
|
+
* @param {number | null} params.height - Coded height.
|
|
17
|
+
* @param {number | null} params.displayWidth
|
|
18
|
+
* @param {number | null} params.displayHeight
|
|
19
|
+
* @param {number | null} params.fps - From container when available (otherwise from ffmpeg banner elsewhere).
|
|
20
|
+
* @param {boolean} params.isHdr - From container colr / ffmpeg detection later.
|
|
21
|
+
* @param {number | null} params.bitDepth
|
|
22
|
+
*/
|
|
23
|
+
constructor(params) {
|
|
24
|
+
super({ ...params, type: "video" });
|
|
25
|
+
this.width = Number.isFinite(params.width) ? params.width : null;
|
|
26
|
+
this.height = Number.isFinite(params.height) ? params.height : null;
|
|
27
|
+
this.displayWidth = Number.isFinite(params.displayWidth) ? params.displayWidth : null;
|
|
28
|
+
this.displayHeight = Number.isFinite(params.displayHeight) ? params.displayHeight : null;
|
|
29
|
+
this.fps = Number.isFinite(params.fps) ? params.fps : null;
|
|
30
|
+
this.isHdr = params.isHdr === true;
|
|
31
|
+
this.bitDepth = Number.isFinite(params.bitDepth) ? params.bitDepth : null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ContainerTrack } from "./ContainerTrack.js";
|
|
2
|
+
export { VideoTrack } from "./VideoTrack.js";
|
|
3
|
+
export { AudioTrack } from "./AudioTrack.js";
|
|
4
|
+
export { SubtitleTrack } from "./SubtitleTrack.js";
|
|
5
|
+
export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
|
|
6
|
+
export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
|
|
7
|
+
export { ExternalSubtitleFile } from "./ExternalSubtitleFile.js";
|
|
@@ -1,158 +1,213 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
|
|
4
|
-
import { allowedGap, readProbeState, PROBE_INTERVAL_MS, 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
|
-
const allowed = {};
|
|
15
|
-
for (const label of overrides.labels ?? ALL) {
|
|
16
|
-
allowed[label] = 3;
|
|
17
|
-
}
|
|
18
|
-
return {
|
|
19
|
-
seq: 100,
|
|
20
|
-
seen,
|
|
21
|
-
labels: ALL,
|
|
22
|
-
echoes: 5,
|
|
23
|
-
echoAgeMs: 400,
|
|
24
|
-
allowed,
|
|
25
|
-
...overrides
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
test("every channel current reads as flowing", () => {
|
|
30
|
-
const { verdict } = readProbeState(state({ proxy: 100, "proxy-control": 99, "proxy-fast": 100 }));
|
|
31
|
-
assert.equal(verdict, "flowing");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("a lag shorter than the verdict window is still flowing", () => {
|
|
35
|
-
const behind = 100 - 3;
|
|
36
|
-
const { verdict } = readProbeState(
|
|
37
|
-
state({ proxy: behind, "proxy-control": behind, "proxy-fast": 100 })
|
|
38
|
-
);
|
|
39
|
-
assert.equal(verdict, "flowing");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("ordered channels behind while the unordered one keeps up names a stuck stream", () => {
|
|
43
|
-
const { verdict, detail } = readProbeState(
|
|
44
|
-
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 100 })
|
|
45
|
-
);
|
|
46
|
-
assert.equal(verdict, "stream-stuck");
|
|
47
|
-
// The numbers that produced the verdict must be in the line beside it.
|
|
48
|
-
assert.match(detail, /proxy=40\(gap 60 of 3\)/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("every channel behind names the association", () => {
|
|
52
|
-
const { verdict } = readProbeState(
|
|
53
|
-
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 })
|
|
54
|
-
);
|
|
55
|
-
assert.equal(verdict, "association-stopped");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("without the unordered channel the verdict says it cannot compare", () => {
|
|
59
|
-
const { verdict } = readProbeState(
|
|
60
|
-
state({ proxy: 40, "proxy-control": 41 }, { labels: ORDERED })
|
|
61
|
-
);
|
|
62
|
-
assert.equal(verdict, "ordered-behind-no-comparison");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("a stale echo means the reverse direction went too", () => {
|
|
66
|
-
const { verdict } = readProbeState(
|
|
67
|
-
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { echoAgeMs: 30_000 })
|
|
68
|
-
);
|
|
69
|
-
assert.equal(verdict, "reverse-direction-gone");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("before the first echo nothing is claimed", () => {
|
|
73
|
-
const { verdict } = readProbeState(state({}, { echoes: 0, echoAgeMs: null }));
|
|
74
|
-
assert.equal(verdict, "no-echo-yet");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("a channel that has never reported counts as behind, not as unknown", () => {
|
|
78
|
-
const { verdict, detail } = readProbeState(
|
|
79
|
-
state({ "proxy-fast": 100 })
|
|
80
|
-
);
|
|
81
|
-
assert.equal(verdict, "stream-stuck");
|
|
82
|
-
assert.match(detail, /proxy=\?\(gap \? of 3\)/);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("the allowance is the queue's own drain time, not a chosen number", () => {
|
|
86
|
-
// 8 MB queued at 8 MB/s is one second of draining; probes go twice a second,
|
|
87
|
-
// so two of them may legitimately be outstanding, plus the round trip.
|
|
88
|
-
assert.equal(
|
|
89
|
-
allowedGap({ queuedBytes: 8 * 1024 * 1024, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }),
|
|
90
|
-
Math.ceil(1000 / PROBE_INTERVAL_MS)
|
|
91
|
-
);
|
|
92
|
-
// An empty queue still allows the one probe that is always in flight.
|
|
93
|
-
assert.equal(allowedGap({ queuedBytes: 0, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }), 1);
|
|
94
|
-
// The round trip counts: the echo has to come back too.
|
|
95
|
-
assert.ok(
|
|
96
|
-
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 2000 }) >
|
|
97
|
-
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 0 })
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("with no rate measured nothing is claimed", () => {
|
|
102
|
-
assert.equal(allowedGap({ queuedBytes: 1024, bytesPerSecond: 0, rttMs: 10 }), null);
|
|
103
|
-
const { verdict } = readProbeState(
|
|
104
|
-
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { allowed: {} })
|
|
105
|
-
);
|
|
106
|
-
assert.equal(verdict, "no-rate-yet");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("a burst big enough to explain the lag is not called a stopped association", () => {
|
|
110
|
-
// The 2026-08-26 false positive: all three channels at gap 7 while 150 Mbps
|
|
111
|
-
// crossed the association. 64 MB queued at 18 MB/s is three and a half
|
|
112
|
-
// seconds of draining, which is seven probe intervals.
|
|
113
|
-
const allowance = allowedGap({
|
|
114
|
-
queuedBytes: 64 * 1024 * 1024,
|
|
115
|
-
bytesPerSecond: 18 * 1024 * 1024,
|
|
116
|
-
rttMs: 16
|
|
117
|
-
});
|
|
118
|
-
assert.ok(allowance >= 7);
|
|
119
|
-
const allowed = Object.fromEntries(ALL.map((label) => [label, allowance]));
|
|
120
|
-
const { verdict } = readProbeState(
|
|
121
|
-
state({ proxy: 93, "proxy-control": 93, "proxy-fast": 93 }, { allowed })
|
|
122
|
-
);
|
|
123
|
-
assert.equal(verdict, "flowing");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("the peer's own answering cadence counts toward the allowance", () => {
|
|
127
|
-
// Field case 2026-08-27: queues empty, 3.4 MB/s crossing, tab hidden so the
|
|
128
|
-
// browser echoed about once a second. Without the peer's cadence the
|
|
129
|
-
// allowance is one probe and every other line read `association-stopped`.
|
|
130
|
-
const withoutCadence = allowedGap({
|
|
131
|
-
queuedBytes: 0,
|
|
132
|
-
bytesPerSecond: 3.4 * 1024 * 1024,
|
|
133
|
-
rttMs: 9
|
|
134
|
-
});
|
|
135
|
-
assert.equal(withoutCadence, 1);
|
|
136
|
-
const withCadence = allowedGap({
|
|
137
|
-
queuedBytes: 0,
|
|
138
|
-
bytesPerSecond: 3.4 * 1024 * 1024,
|
|
139
|
-
rttMs: 9,
|
|
140
|
-
echoIntervalMs: 1000
|
|
141
|
-
});
|
|
142
|
-
assert.ok(withCadence >= 3, `a second of cadence must allow more than ${withCadence}`);
|
|
143
|
-
const allowed = Object.fromEntries(ALL.map((label) => [label, withCadence]));
|
|
144
|
-
const { verdict } = readProbeState(
|
|
145
|
-
state({ proxy: 98, "proxy-control": 98, "proxy-fast": 98 }, { allowed, echoAgeMs: 977 })
|
|
146
|
-
);
|
|
147
|
-
assert.equal(verdict, "flowing");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("a stale echo is judged against the peer's cadence, not a fixed half second", () => {
|
|
151
|
-
// The same numbers with the cadence unknown must still be able to say the
|
|
152
|
-
// reverse direction is gone — the bound rises with the cadence, it does not
|
|
153
|
-
// disappear.
|
|
154
|
-
const { verdict } = readProbeState(
|
|
155
|
-
state({ proxy: 99, "proxy-control": 99, "proxy-fast": 99 }, { echoAgeMs: 60_000, echoStaleMs: 2000 })
|
|
156
|
-
);
|
|
157
|
-
assert.equal(verdict, "reverse-direction-gone");
|
|
158
|
-
});
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { allowedGap, readProbeState, PROBE_INTERVAL_MS, 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
|
+
const allowed = {};
|
|
15
|
+
for (const label of overrides.labels ?? ALL) {
|
|
16
|
+
allowed[label] = 3;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
seq: 100,
|
|
20
|
+
seen,
|
|
21
|
+
labels: ALL,
|
|
22
|
+
echoes: 5,
|
|
23
|
+
echoAgeMs: 400,
|
|
24
|
+
allowed,
|
|
25
|
+
...overrides
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("every channel current reads as flowing", () => {
|
|
30
|
+
const { verdict } = readProbeState(state({ proxy: 100, "proxy-control": 99, "proxy-fast": 100 }));
|
|
31
|
+
assert.equal(verdict, "flowing");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a lag shorter than the verdict window is still flowing", () => {
|
|
35
|
+
const behind = 100 - 3;
|
|
36
|
+
const { verdict } = readProbeState(
|
|
37
|
+
state({ proxy: behind, "proxy-control": behind, "proxy-fast": 100 })
|
|
38
|
+
);
|
|
39
|
+
assert.equal(verdict, "flowing");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("ordered channels behind while the unordered one keeps up names a stuck stream", () => {
|
|
43
|
+
const { verdict, detail } = readProbeState(
|
|
44
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 100 })
|
|
45
|
+
);
|
|
46
|
+
assert.equal(verdict, "stream-stuck");
|
|
47
|
+
// The numbers that produced the verdict must be in the line beside it.
|
|
48
|
+
assert.match(detail, /proxy=40\(gap 60 of 3\)/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("every channel behind names the association", () => {
|
|
52
|
+
const { verdict } = readProbeState(
|
|
53
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 })
|
|
54
|
+
);
|
|
55
|
+
assert.equal(verdict, "association-stopped");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("without the unordered channel the verdict says it cannot compare", () => {
|
|
59
|
+
const { verdict } = readProbeState(
|
|
60
|
+
state({ proxy: 40, "proxy-control": 41 }, { labels: ORDERED })
|
|
61
|
+
);
|
|
62
|
+
assert.equal(verdict, "ordered-behind-no-comparison");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a stale echo means the reverse direction went too", () => {
|
|
66
|
+
const { verdict } = readProbeState(
|
|
67
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { echoAgeMs: 30_000 })
|
|
68
|
+
);
|
|
69
|
+
assert.equal(verdict, "reverse-direction-gone");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("before the first echo nothing is claimed", () => {
|
|
73
|
+
const { verdict } = readProbeState(state({}, { echoes: 0, echoAgeMs: null }));
|
|
74
|
+
assert.equal(verdict, "no-echo-yet");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a channel that has never reported counts as behind, not as unknown", () => {
|
|
78
|
+
const { verdict, detail } = readProbeState(
|
|
79
|
+
state({ "proxy-fast": 100 })
|
|
80
|
+
);
|
|
81
|
+
assert.equal(verdict, "stream-stuck");
|
|
82
|
+
assert.match(detail, /proxy=\?\(gap \? of 3\)/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the allowance is the queue's own drain time, not a chosen number", () => {
|
|
86
|
+
// 8 MB queued at 8 MB/s is one second of draining; probes go twice a second,
|
|
87
|
+
// so two of them may legitimately be outstanding, plus the round trip.
|
|
88
|
+
assert.equal(
|
|
89
|
+
allowedGap({ queuedBytes: 8 * 1024 * 1024, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }),
|
|
90
|
+
Math.ceil(1000 / PROBE_INTERVAL_MS)
|
|
91
|
+
);
|
|
92
|
+
// An empty queue still allows the one probe that is always in flight.
|
|
93
|
+
assert.equal(allowedGap({ queuedBytes: 0, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }), 1);
|
|
94
|
+
// The round trip counts: the echo has to come back too.
|
|
95
|
+
assert.ok(
|
|
96
|
+
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 2000 }) >
|
|
97
|
+
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 0 })
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("with no rate measured nothing is claimed", () => {
|
|
102
|
+
assert.equal(allowedGap({ queuedBytes: 1024, bytesPerSecond: 0, rttMs: 10 }), null);
|
|
103
|
+
const { verdict } = readProbeState(
|
|
104
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { allowed: {} })
|
|
105
|
+
);
|
|
106
|
+
assert.equal(verdict, "no-rate-yet");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a burst big enough to explain the lag is not called a stopped association", () => {
|
|
110
|
+
// The 2026-08-26 false positive: all three channels at gap 7 while 150 Mbps
|
|
111
|
+
// crossed the association. 64 MB queued at 18 MB/s is three and a half
|
|
112
|
+
// seconds of draining, which is seven probe intervals.
|
|
113
|
+
const allowance = allowedGap({
|
|
114
|
+
queuedBytes: 64 * 1024 * 1024,
|
|
115
|
+
bytesPerSecond: 18 * 1024 * 1024,
|
|
116
|
+
rttMs: 16
|
|
117
|
+
});
|
|
118
|
+
assert.ok(allowance >= 7);
|
|
119
|
+
const allowed = Object.fromEntries(ALL.map((label) => [label, allowance]));
|
|
120
|
+
const { verdict } = readProbeState(
|
|
121
|
+
state({ proxy: 93, "proxy-control": 93, "proxy-fast": 93 }, { allowed })
|
|
122
|
+
);
|
|
123
|
+
assert.equal(verdict, "flowing");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the peer's own answering cadence counts toward the allowance", () => {
|
|
127
|
+
// Field case 2026-08-27: queues empty, 3.4 MB/s crossing, tab hidden so the
|
|
128
|
+
// browser echoed about once a second. Without the peer's cadence the
|
|
129
|
+
// allowance is one probe and every other line read `association-stopped`.
|
|
130
|
+
const withoutCadence = allowedGap({
|
|
131
|
+
queuedBytes: 0,
|
|
132
|
+
bytesPerSecond: 3.4 * 1024 * 1024,
|
|
133
|
+
rttMs: 9
|
|
134
|
+
});
|
|
135
|
+
assert.equal(withoutCadence, 1);
|
|
136
|
+
const withCadence = allowedGap({
|
|
137
|
+
queuedBytes: 0,
|
|
138
|
+
bytesPerSecond: 3.4 * 1024 * 1024,
|
|
139
|
+
rttMs: 9,
|
|
140
|
+
echoIntervalMs: 1000
|
|
141
|
+
});
|
|
142
|
+
assert.ok(withCadence >= 3, `a second of cadence must allow more than ${withCadence}`);
|
|
143
|
+
const allowed = Object.fromEntries(ALL.map((label) => [label, withCadence]));
|
|
144
|
+
const { verdict } = readProbeState(
|
|
145
|
+
state({ proxy: 98, "proxy-control": 98, "proxy-fast": 98 }, { allowed, echoAgeMs: 977 })
|
|
146
|
+
);
|
|
147
|
+
assert.equal(verdict, "flowing");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a stale echo is judged against the peer's cadence, not a fixed half second", () => {
|
|
151
|
+
// The same numbers with the cadence unknown must still be able to say the
|
|
152
|
+
// reverse direction is gone — the bound rises with the cadence, it does not
|
|
153
|
+
// disappear.
|
|
154
|
+
const { verdict } = readProbeState(
|
|
155
|
+
state({ proxy: 99, "proxy-control": 99, "proxy-fast": 99 }, { echoAgeMs: 60_000, echoStaleMs: 2000 })
|
|
156
|
+
);
|
|
157
|
+
assert.equal(verdict, "reverse-direction-gone");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("bytes still arriving outrank the probe gaps — the cushion fill is not a wedge", () => {
|
|
161
|
+
// The field shape of 2026-08-28: every queue at 0 B, so the allowance is
|
|
162
|
+
// small, the probes are far behind because the browser is busy pulling two
|
|
163
|
+
// minutes of film, and the association is perfectly healthy. Before the peer's
|
|
164
|
+
// own byte counter was consulted this read as `association-stopped`, four
|
|
165
|
+
// times in the first two minutes of a session nobody was troubled by.
|
|
166
|
+
const state = {
|
|
167
|
+
seq: 88,
|
|
168
|
+
seen: { proxy: 78, "proxy-control": 78, "proxy-fast": 78 },
|
|
169
|
+
labels: ["proxy", "proxy-control", "proxy-fast"],
|
|
170
|
+
echoes: 40,
|
|
171
|
+
echoAgeMs: 1305,
|
|
172
|
+
allowed: { proxy: 9, "proxy-control": 9, "proxy-fast": 9 },
|
|
173
|
+
echoStaleMs: 6000,
|
|
174
|
+
peerBytesAdvancing: true
|
|
175
|
+
};
|
|
176
|
+
const reading = readProbeState(state);
|
|
177
|
+
assert.equal(reading.verdict, "flowing");
|
|
178
|
+
assert.match(reading.detail, /peerBytes=advancing/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the same gaps with the peer's counter STILL are the wedge", () => {
|
|
182
|
+
// One field changes, and it is the one that says whether anything is
|
|
183
|
+
// arriving. This is the shape of a real freeze: probes behind, and the far
|
|
184
|
+
// end receiving nothing.
|
|
185
|
+
const reading = readProbeState({
|
|
186
|
+
seq: 88,
|
|
187
|
+
seen: { proxy: 78, "proxy-control": 78, "proxy-fast": 78 },
|
|
188
|
+
labels: ["proxy", "proxy-control", "proxy-fast"],
|
|
189
|
+
echoes: 40,
|
|
190
|
+
echoAgeMs: 1305,
|
|
191
|
+
allowed: { proxy: 9, "proxy-control": 9, "proxy-fast": 9 },
|
|
192
|
+
echoStaleMs: 6000,
|
|
193
|
+
peerBytesAdvancing: false
|
|
194
|
+
});
|
|
195
|
+
assert.equal(reading.verdict, "association-stopped");
|
|
196
|
+
assert.match(reading.detail, /peerBytes=still/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a browser that does not report its bytes is judged as before", () => {
|
|
200
|
+
// The term says nothing rather than guessing, and the rule falls back.
|
|
201
|
+
const reading = readProbeState({
|
|
202
|
+
seq: 88,
|
|
203
|
+
seen: { proxy: 78, "proxy-control": 78, "proxy-fast": 78 },
|
|
204
|
+
labels: ["proxy", "proxy-control", "proxy-fast"],
|
|
205
|
+
echoes: 40,
|
|
206
|
+
echoAgeMs: 1305,
|
|
207
|
+
allowed: { proxy: 9, "proxy-control": 9, "proxy-fast": 9 },
|
|
208
|
+
echoStaleMs: 6000,
|
|
209
|
+
peerBytesAdvancing: null
|
|
210
|
+
});
|
|
211
|
+
assert.equal(reading.verdict, "association-stopped");
|
|
212
|
+
assert.doesNotMatch(reading.detail, /peerBytes=/);
|
|
213
|
+
});
|