@torrent-tv/proxy 2.80.19 → 2.81.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 +12 -0
- package/package.json +1 -1
- package/research/double-spawn-2026-09-10.md +171 -0
- package/services/disk/DiskSpace.js +146 -0
- package/services/disk/wire.js +60 -0
- package/services/encode/EncodeRun.js +25 -3
- package/services/encode/SegmentStore.js +137 -9
- package/services/hls-session-manager.js +26 -45
- package/services/orchestrators/EncodeOrchestrator.js +4 -1
- package/services/piece-store/allowance.js +107 -0
- package/services/piece-store/piece-disk-store.js +365 -0
- package/services/piece-store/shared-piece-store.js +1549 -1535
- package/services/torrent-worker/client.js +32 -0
- package/services/torrent-worker/pool-adapter.js +15 -0
- package/services/torrent-worker/protocol.js +9 -0
- package/services/torrent-worker/worker.js +8 -1
- package/services/viewer/positions.js +48 -0
- package/test/audio-inventory.test.js +176 -176
- package/test/auto-quality-step.test.js +514 -514
- package/test/concurrent-cost.test.js +138 -138
- package/test/coverage-follows-the-disk.test.js +191 -191
- package/test/coverage-map.test.js +195 -195
- package/test/declared-tracks.test.js +35 -35
- package/test/disk-space.test.js +138 -0
- package/test/encode-orchestrator.test.js +0 -3
- package/test/encode-run.test.js +5 -12
- package/test/held-request-width.test.js +155 -155
- package/test/helpers/encode-run.js +2 -2
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +281 -220
- package/test/orchestrator-wired.test.js +199 -199
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +267 -0
- package/test/piece-reader.test.js +4 -4
- package/test/piece-store-eviction.test.js +17 -17
- package/test/piece-store-reservations.test.js +20 -1
- package/test/piece-store-slow-disk.test.js +16 -1
- package/test/read-window.test.js +6 -6
- package/test/run-intervals.test.js +100 -100
- package/test/seek-landing.test.js +109 -109
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segments-are-shared.test.js +1 -1
- package/test/shared-piece-store.test.js +12 -12
- package/test/sidecar-naming.test.js +142 -142
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-walk.test.js +369 -369
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-track-numbering.test.js +370 -370
- package/test/tail-duplication.test.js +167 -167
- package/test/tracks-begin-together.test.js +195 -195
- package/test/two-viewers-one-picture.test.js +374 -374
- package/test/video-facts.test.js +102 -102
- package/test/wedge-certainty.test.js +131 -131
- package/services/piece-store/disk-tier.js +0 -151
package/test/video-facts.test.js
CHANGED
|
@@ -1,102 +1,102 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The picture's facts, from the two readings that state them.
|
|
3
|
-
*
|
|
4
|
-
* Audio and subtitles have had this reconciliation since their flags were first
|
|
5
|
-
* read from the file. Video never did: every figure the encode is planned from
|
|
6
|
-
* came from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
|
|
7
|
-
* declares was read and then used for nothing but a line in the log — so a file
|
|
8
|
-
* that states its bit depth or its HDR signalling, against a probe that does
|
|
9
|
-
* not print them, disagreed with nobody watching.
|
|
10
|
-
*
|
|
11
|
-
* What each check pins is WHICH reading answers, and why: the size and the
|
|
12
|
-
* frame rate are what the decoder will produce, so the probe answers; the bit
|
|
13
|
-
* depth and the HDR signalling are the file saying how its samples are to be
|
|
14
|
-
* read, so the container answers.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import test from "node:test";
|
|
18
|
-
import assert from "node:assert/strict";
|
|
19
|
-
|
|
20
|
-
import { Container } from "../services/container/Container.js";
|
|
21
|
-
|
|
22
|
-
const banner = (fields) => ({ width: null, height: null, fps: null, isHdr: false, bitDepth: null, ...fields });
|
|
23
|
-
|
|
24
|
-
test("with no container reading the probe answers for everything", () => {
|
|
25
|
-
const facts = Container.mergeVideoFacts(
|
|
26
|
-
banner({ width: 1920, height: 1080, fps: 23.976, isHdr: true, bitDepth: 10 }),
|
|
27
|
-
null
|
|
28
|
-
);
|
|
29
|
-
assert.equal(facts.width, 1920);
|
|
30
|
-
assert.equal(facts.height, 1080);
|
|
31
|
-
assert.equal(facts.fps, 23.976);
|
|
32
|
-
assert.equal(facts.isHdr, true);
|
|
33
|
-
assert.equal(facts.bitDepth, 10);
|
|
34
|
-
assert.deepEqual(facts.disagreements, []);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("the size and the frame rate are the probe's, because that is what will be decoded", () => {
|
|
38
|
-
const facts = Container.mergeVideoFacts(
|
|
39
|
-
banner({ width: 1920, height: 1080, fps: 24 }),
|
|
40
|
-
{ width: 1440, height: 1080, fps: 25, displayWidth: 1920, displayHeight: 1080 }
|
|
41
|
-
);
|
|
42
|
-
assert.equal(facts.width, 1920, "the ladder and the scale filter are sized to the decoded frame");
|
|
43
|
-
assert.equal(facts.height, 1080);
|
|
44
|
-
assert.equal(facts.fps, 24);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("the bit depth and the HDR signalling are the file's, because the file states them", () => {
|
|
48
|
-
// A ten-bit HEVC whose probe printed neither: this is the case that decides
|
|
49
|
-
// whether tone mapping runs and how the decode cost is priced.
|
|
50
|
-
const facts = Container.mergeVideoFacts(
|
|
51
|
-
banner({ width: 3840, height: 2160, fps: 24 }),
|
|
52
|
-
{ width: 3840, height: 2160, bitDepth: 10, isHdr: true }
|
|
53
|
-
);
|
|
54
|
-
assert.equal(facts.bitDepth, 10);
|
|
55
|
-
assert.equal(facts.isHdr, true);
|
|
56
|
-
assert.deepEqual(facts.disagreements, [], "one side saying nothing is not a disagreement");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("a field only the probe states is still answered", () => {
|
|
60
|
-
const facts = Container.mergeVideoFacts(
|
|
61
|
-
banner({ width: 1280, height: 720, fps: 30, bitDepth: 8 }),
|
|
62
|
-
{ width: null, height: null, fps: null, bitDepth: null }
|
|
63
|
-
);
|
|
64
|
-
assert.equal(facts.width, 1280);
|
|
65
|
-
assert.equal(facts.bitDepth, 8);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("a real disagreement is reported, with both values and which is which", () => {
|
|
69
|
-
const facts = Container.mergeVideoFacts(
|
|
70
|
-
banner({ width: 1920, height: 1080, bitDepth: 8, isHdr: false }),
|
|
71
|
-
{ width: 1280, height: 720, bitDepth: 10, isHdr: true }
|
|
72
|
-
);
|
|
73
|
-
assert.equal(facts.disagreements.length, 3);
|
|
74
|
-
assert.ok(facts.disagreements.some((line) => /width 1280 in the container against 1920 in the probe/.test(line)));
|
|
75
|
-
assert.ok(facts.disagreements.some((line) => /bit depth 10 in the container against 8 in the probe/.test(line)));
|
|
76
|
-
// HDR is not among them, and cannot be: see `mergeVideoFacts`.
|
|
77
|
-
assert.ok(!facts.disagreements.some((line) => /HDR/.test(line)));
|
|
78
|
-
// And the rule still decides: the probe for the size, the file for the rest.
|
|
79
|
-
assert.equal(facts.width, 1920);
|
|
80
|
-
assert.equal(facts.bitDepth, 10);
|
|
81
|
-
assert.equal(facts.isHdr, true);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("the display size is the container's alone — the probe has no such field", () => {
|
|
85
|
-
const facts = Container.mergeVideoFacts(
|
|
86
|
-
banner({ width: 1440, height: 1080 }),
|
|
87
|
-
{ width: 1440, height: 1080, displayWidth: 1920, displayHeight: 1080 }
|
|
88
|
-
);
|
|
89
|
-
assert.equal(facts.displayWidth, 1920);
|
|
90
|
-
assert.equal(facts.displayHeight, 1080);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("zero and nonsense are not values", () => {
|
|
94
|
-
const facts = Container.mergeVideoFacts(
|
|
95
|
-
banner({ width: 0, height: 0, fps: 0, bitDepth: 0 }),
|
|
96
|
-
{ width: 1920, height: 1080, fps: 24, bitDepth: 8 }
|
|
97
|
-
);
|
|
98
|
-
assert.equal(facts.width, 1920, "a probe that printed nothing does not outrank a file that speaks");
|
|
99
|
-
assert.equal(facts.fps, 24);
|
|
100
|
-
assert.equal(facts.bitDepth, 8);
|
|
101
|
-
assert.deepEqual(facts.disagreements, []);
|
|
102
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file The picture's facts, from the two readings that state them.
|
|
3
|
+
*
|
|
4
|
+
* Audio and subtitles have had this reconciliation since their flags were first
|
|
5
|
+
* read from the file. Video never did: every figure the encode is planned from
|
|
6
|
+
* came from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
|
|
7
|
+
* declares was read and then used for nothing but a line in the log — so a file
|
|
8
|
+
* that states its bit depth or its HDR signalling, against a probe that does
|
|
9
|
+
* not print them, disagreed with nobody watching.
|
|
10
|
+
*
|
|
11
|
+
* What each check pins is WHICH reading answers, and why: the size and the
|
|
12
|
+
* frame rate are what the decoder will produce, so the probe answers; the bit
|
|
13
|
+
* depth and the HDR signalling are the file saying how its samples are to be
|
|
14
|
+
* read, so the container answers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
|
|
20
|
+
import { Container } from "../services/container/Container.js";
|
|
21
|
+
|
|
22
|
+
const banner = (fields) => ({ width: null, height: null, fps: null, isHdr: false, bitDepth: null, ...fields });
|
|
23
|
+
|
|
24
|
+
test("with no container reading the probe answers for everything", () => {
|
|
25
|
+
const facts = Container.mergeVideoFacts(
|
|
26
|
+
banner({ width: 1920, height: 1080, fps: 23.976, isHdr: true, bitDepth: 10 }),
|
|
27
|
+
null
|
|
28
|
+
);
|
|
29
|
+
assert.equal(facts.width, 1920);
|
|
30
|
+
assert.equal(facts.height, 1080);
|
|
31
|
+
assert.equal(facts.fps, 23.976);
|
|
32
|
+
assert.equal(facts.isHdr, true);
|
|
33
|
+
assert.equal(facts.bitDepth, 10);
|
|
34
|
+
assert.deepEqual(facts.disagreements, []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the size and the frame rate are the probe's, because that is what will be decoded", () => {
|
|
38
|
+
const facts = Container.mergeVideoFacts(
|
|
39
|
+
banner({ width: 1920, height: 1080, fps: 24 }),
|
|
40
|
+
{ width: 1440, height: 1080, fps: 25, displayWidth: 1920, displayHeight: 1080 }
|
|
41
|
+
);
|
|
42
|
+
assert.equal(facts.width, 1920, "the ladder and the scale filter are sized to the decoded frame");
|
|
43
|
+
assert.equal(facts.height, 1080);
|
|
44
|
+
assert.equal(facts.fps, 24);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the bit depth and the HDR signalling are the file's, because the file states them", () => {
|
|
48
|
+
// A ten-bit HEVC whose probe printed neither: this is the case that decides
|
|
49
|
+
// whether tone mapping runs and how the decode cost is priced.
|
|
50
|
+
const facts = Container.mergeVideoFacts(
|
|
51
|
+
banner({ width: 3840, height: 2160, fps: 24 }),
|
|
52
|
+
{ width: 3840, height: 2160, bitDepth: 10, isHdr: true }
|
|
53
|
+
);
|
|
54
|
+
assert.equal(facts.bitDepth, 10);
|
|
55
|
+
assert.equal(facts.isHdr, true);
|
|
56
|
+
assert.deepEqual(facts.disagreements, [], "one side saying nothing is not a disagreement");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a field only the probe states is still answered", () => {
|
|
60
|
+
const facts = Container.mergeVideoFacts(
|
|
61
|
+
banner({ width: 1280, height: 720, fps: 30, bitDepth: 8 }),
|
|
62
|
+
{ width: null, height: null, fps: null, bitDepth: null }
|
|
63
|
+
);
|
|
64
|
+
assert.equal(facts.width, 1280);
|
|
65
|
+
assert.equal(facts.bitDepth, 8);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a real disagreement is reported, with both values and which is which", () => {
|
|
69
|
+
const facts = Container.mergeVideoFacts(
|
|
70
|
+
banner({ width: 1920, height: 1080, bitDepth: 8, isHdr: false }),
|
|
71
|
+
{ width: 1280, height: 720, bitDepth: 10, isHdr: true }
|
|
72
|
+
);
|
|
73
|
+
assert.equal(facts.disagreements.length, 3);
|
|
74
|
+
assert.ok(facts.disagreements.some((line) => /width 1280 in the container against 1920 in the probe/.test(line)));
|
|
75
|
+
assert.ok(facts.disagreements.some((line) => /bit depth 10 in the container against 8 in the probe/.test(line)));
|
|
76
|
+
// HDR is not among them, and cannot be: see `mergeVideoFacts`.
|
|
77
|
+
assert.ok(!facts.disagreements.some((line) => /HDR/.test(line)));
|
|
78
|
+
// And the rule still decides: the probe for the size, the file for the rest.
|
|
79
|
+
assert.equal(facts.width, 1920);
|
|
80
|
+
assert.equal(facts.bitDepth, 10);
|
|
81
|
+
assert.equal(facts.isHdr, true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the display size is the container's alone — the probe has no such field", () => {
|
|
85
|
+
const facts = Container.mergeVideoFacts(
|
|
86
|
+
banner({ width: 1440, height: 1080 }),
|
|
87
|
+
{ width: 1440, height: 1080, displayWidth: 1920, displayHeight: 1080 }
|
|
88
|
+
);
|
|
89
|
+
assert.equal(facts.displayWidth, 1920);
|
|
90
|
+
assert.equal(facts.displayHeight, 1080);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("zero and nonsense are not values", () => {
|
|
94
|
+
const facts = Container.mergeVideoFacts(
|
|
95
|
+
banner({ width: 0, height: 0, fps: 0, bitDepth: 0 }),
|
|
96
|
+
{ width: 1920, height: 1080, fps: 24, bitDepth: 8 }
|
|
97
|
+
);
|
|
98
|
+
assert.equal(facts.width, 1920, "a probe that printed nothing does not outrank a file that speaks");
|
|
99
|
+
assert.equal(facts.fps, 24);
|
|
100
|
+
assert.equal(facts.bitDepth, 8);
|
|
101
|
+
assert.deepEqual(facts.disagreements, []);
|
|
102
|
+
});
|
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
|
|
4
|
-
import { wedgeIsCertain } from "../services/data-channel-handler.js";
|
|
5
|
-
import { PROBE_INTERVAL_MS } from "../services/delivery-probe.js";
|
|
6
|
-
|
|
7
|
-
const MEGABYTE = 1024 * 1024;
|
|
8
|
-
|
|
9
|
-
test("a queue draining at the rate this link was measured at is not a wedge", () => {
|
|
10
|
-
// 8 MB at 16 MB/s is half a second of draining. Half a second in is normal.
|
|
11
|
-
const verdict = wedgeIsCertain({
|
|
12
|
-
queuedBytes: 8 * MEGABYTE,
|
|
13
|
-
bytesPerSecond: 16 * MEGABYTE,
|
|
14
|
-
flatForMs: 200
|
|
15
|
-
});
|
|
16
|
-
assert.equal(verdict.certain, false);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("the counter standing still past the queue's own drain time is a wedge", () => {
|
|
20
|
-
const verdict = wedgeIsCertain({
|
|
21
|
-
queuedBytes: 8 * MEGABYTE,
|
|
22
|
-
bytesPerSecond: 16 * MEGABYTE,
|
|
23
|
-
flatForMs: 4000
|
|
24
|
-
});
|
|
25
|
-
assert.equal(verdict.certain, true);
|
|
26
|
-
assert.equal(verdict.needMs, 500);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("a big queue on a slow link is given the time it genuinely needs", () => {
|
|
30
|
-
// 67 MB at 1 MB/s is 67 seconds of honest draining — the field's own numbers
|
|
31
|
-
// for the wedged channel, at a rate a thin link could really be running at.
|
|
32
|
-
const patient = wedgeIsCertain({
|
|
33
|
-
queuedBytes: 67 * MEGABYTE,
|
|
34
|
-
bytesPerSecond: MEGABYTE,
|
|
35
|
-
flatForMs: 30_000
|
|
36
|
-
});
|
|
37
|
-
assert.equal(patient.certain, false);
|
|
38
|
-
const later = wedgeIsCertain({
|
|
39
|
-
queuedBytes: 67 * MEGABYTE,
|
|
40
|
-
bytesPerSecond: MEGABYTE,
|
|
41
|
-
flatForMs: 70_000
|
|
42
|
-
});
|
|
43
|
-
assert.equal(later.certain, true);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("the same queue on the link the field actually had is called quickly", () => {
|
|
47
|
-
// 67 MB at 18 MB/s is under four seconds. The field episode stood still for
|
|
48
|
-
// 3217 s; the previous rule waited a flat 30 s before recording anything.
|
|
49
|
-
const verdict = wedgeIsCertain({
|
|
50
|
-
queuedBytes: 67 * MEGABYTE,
|
|
51
|
-
bytesPerSecond: 18 * MEGABYTE,
|
|
52
|
-
flatForMs: 5000
|
|
53
|
-
});
|
|
54
|
-
assert.equal(verdict.certain, true);
|
|
55
|
-
assert.ok(verdict.needMs < 30_000);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("a tiny queue still waits for one round of probes", () => {
|
|
59
|
-
const verdict = wedgeIsCertain({
|
|
60
|
-
queuedBytes: 512,
|
|
61
|
-
bytesPerSecond: 16 * MEGABYTE,
|
|
62
|
-
flatForMs: PROBE_INTERVAL_MS - 1
|
|
63
|
-
});
|
|
64
|
-
assert.equal(verdict.certain, false);
|
|
65
|
-
assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("nothing queued is still a wedge when the counter has been flat long enough — the channel queue was 0 on the real wedge while usrsctp held 399 MB", () => {
|
|
69
|
-
const verdict = wedgeIsCertain({
|
|
70
|
-
queuedBytes: 0,
|
|
71
|
-
bytesPerSecond: 16 * MEGABYTE,
|
|
72
|
-
flatForMs: 600_000
|
|
73
|
-
});
|
|
74
|
-
assert.equal(verdict.certain, true);
|
|
75
|
-
assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("with no rate measured the answer is that nothing can be said", () => {
|
|
79
|
-
const verdict = wedgeIsCertain({
|
|
80
|
-
queuedBytes: 8 * MEGABYTE,
|
|
81
|
-
bytesPerSecond: 0,
|
|
82
|
-
flatForMs: 600_000
|
|
83
|
-
});
|
|
84
|
-
assert.equal(verdict.certain, false);
|
|
85
|
-
assert.equal(verdict.needMs, null);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("a pause no longer than this link's own longest healthy pause is not a wedge", () => {
|
|
89
|
-
// A retransmission timeout stops the accepted-byte counter dead: a full send
|
|
90
|
-
// buffer accepts nothing. If this connection has already paused 3 s while
|
|
91
|
-
// healthy, a 3 s pause says nothing.
|
|
92
|
-
const verdict = wedgeIsCertain({
|
|
93
|
-
queuedBytes: 8 * MEGABYTE,
|
|
94
|
-
bytesPerSecond: 18 * MEGABYTE,
|
|
95
|
-
flatForMs: 2500,
|
|
96
|
-
longestHealthyFlatMs: 3000
|
|
97
|
-
});
|
|
98
|
-
assert.equal(verdict.certain, false);
|
|
99
|
-
assert.equal(verdict.needMs, 3000);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("a pause longer than any this link has shown is a wedge", () => {
|
|
103
|
-
const verdict = wedgeIsCertain({
|
|
104
|
-
queuedBytes: 8 * MEGABYTE,
|
|
105
|
-
bytesPerSecond: 18 * MEGABYTE,
|
|
106
|
-
flatForMs: 3500,
|
|
107
|
-
longestHealthyFlatMs: 3000
|
|
108
|
-
});
|
|
109
|
-
assert.equal(verdict.certain, true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("the quiet-probe rate cannot be what the queue is divided by", () => {
|
|
113
|
-
// With the browser's buffer full, the only traffic is the probe: three
|
|
114
|
-
// channels, a few dozen bytes, twice a second. Dividing 8 MB by that gives
|
|
115
|
-
// six hours, and the wedge would never be called. The BEST rate seen is what
|
|
116
|
-
// the watcher keeps, so this case must not arise — pinned here as the
|
|
117
|
-
// arithmetic that made it matter.
|
|
118
|
-
const wrong = wedgeIsCertain({
|
|
119
|
-
queuedBytes: 8 * MEGABYTE,
|
|
120
|
-
bytesPerSecond: 360,
|
|
121
|
-
flatForMs: 60_000
|
|
122
|
-
});
|
|
123
|
-
assert.equal(wrong.certain, false);
|
|
124
|
-
assert.ok(wrong.needMs > 6 * 60 * 60 * 1000 - 1);
|
|
125
|
-
const right = wedgeIsCertain({
|
|
126
|
-
queuedBytes: 8 * MEGABYTE,
|
|
127
|
-
bytesPerSecond: 18 * MEGABYTE,
|
|
128
|
-
flatForMs: 60_000
|
|
129
|
-
});
|
|
130
|
-
assert.equal(right.certain, true);
|
|
131
|
-
});
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { wedgeIsCertain } from "../services/data-channel-handler.js";
|
|
5
|
+
import { PROBE_INTERVAL_MS } from "../services/delivery-probe.js";
|
|
6
|
+
|
|
7
|
+
const MEGABYTE = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
test("a queue draining at the rate this link was measured at is not a wedge", () => {
|
|
10
|
+
// 8 MB at 16 MB/s is half a second of draining. Half a second in is normal.
|
|
11
|
+
const verdict = wedgeIsCertain({
|
|
12
|
+
queuedBytes: 8 * MEGABYTE,
|
|
13
|
+
bytesPerSecond: 16 * MEGABYTE,
|
|
14
|
+
flatForMs: 200
|
|
15
|
+
});
|
|
16
|
+
assert.equal(verdict.certain, false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("the counter standing still past the queue's own drain time is a wedge", () => {
|
|
20
|
+
const verdict = wedgeIsCertain({
|
|
21
|
+
queuedBytes: 8 * MEGABYTE,
|
|
22
|
+
bytesPerSecond: 16 * MEGABYTE,
|
|
23
|
+
flatForMs: 4000
|
|
24
|
+
});
|
|
25
|
+
assert.equal(verdict.certain, true);
|
|
26
|
+
assert.equal(verdict.needMs, 500);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a big queue on a slow link is given the time it genuinely needs", () => {
|
|
30
|
+
// 67 MB at 1 MB/s is 67 seconds of honest draining — the field's own numbers
|
|
31
|
+
// for the wedged channel, at a rate a thin link could really be running at.
|
|
32
|
+
const patient = wedgeIsCertain({
|
|
33
|
+
queuedBytes: 67 * MEGABYTE,
|
|
34
|
+
bytesPerSecond: MEGABYTE,
|
|
35
|
+
flatForMs: 30_000
|
|
36
|
+
});
|
|
37
|
+
assert.equal(patient.certain, false);
|
|
38
|
+
const later = wedgeIsCertain({
|
|
39
|
+
queuedBytes: 67 * MEGABYTE,
|
|
40
|
+
bytesPerSecond: MEGABYTE,
|
|
41
|
+
flatForMs: 70_000
|
|
42
|
+
});
|
|
43
|
+
assert.equal(later.certain, true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the same queue on the link the field actually had is called quickly", () => {
|
|
47
|
+
// 67 MB at 18 MB/s is under four seconds. The field episode stood still for
|
|
48
|
+
// 3217 s; the previous rule waited a flat 30 s before recording anything.
|
|
49
|
+
const verdict = wedgeIsCertain({
|
|
50
|
+
queuedBytes: 67 * MEGABYTE,
|
|
51
|
+
bytesPerSecond: 18 * MEGABYTE,
|
|
52
|
+
flatForMs: 5000
|
|
53
|
+
});
|
|
54
|
+
assert.equal(verdict.certain, true);
|
|
55
|
+
assert.ok(verdict.needMs < 30_000);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a tiny queue still waits for one round of probes", () => {
|
|
59
|
+
const verdict = wedgeIsCertain({
|
|
60
|
+
queuedBytes: 512,
|
|
61
|
+
bytesPerSecond: 16 * MEGABYTE,
|
|
62
|
+
flatForMs: PROBE_INTERVAL_MS - 1
|
|
63
|
+
});
|
|
64
|
+
assert.equal(verdict.certain, false);
|
|
65
|
+
assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("nothing queued is still a wedge when the counter has been flat long enough — the channel queue was 0 on the real wedge while usrsctp held 399 MB", () => {
|
|
69
|
+
const verdict = wedgeIsCertain({
|
|
70
|
+
queuedBytes: 0,
|
|
71
|
+
bytesPerSecond: 16 * MEGABYTE,
|
|
72
|
+
flatForMs: 600_000
|
|
73
|
+
});
|
|
74
|
+
assert.equal(verdict.certain, true);
|
|
75
|
+
assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("with no rate measured the answer is that nothing can be said", () => {
|
|
79
|
+
const verdict = wedgeIsCertain({
|
|
80
|
+
queuedBytes: 8 * MEGABYTE,
|
|
81
|
+
bytesPerSecond: 0,
|
|
82
|
+
flatForMs: 600_000
|
|
83
|
+
});
|
|
84
|
+
assert.equal(verdict.certain, false);
|
|
85
|
+
assert.equal(verdict.needMs, null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a pause no longer than this link's own longest healthy pause is not a wedge", () => {
|
|
89
|
+
// A retransmission timeout stops the accepted-byte counter dead: a full send
|
|
90
|
+
// buffer accepts nothing. If this connection has already paused 3 s while
|
|
91
|
+
// healthy, a 3 s pause says nothing.
|
|
92
|
+
const verdict = wedgeIsCertain({
|
|
93
|
+
queuedBytes: 8 * MEGABYTE,
|
|
94
|
+
bytesPerSecond: 18 * MEGABYTE,
|
|
95
|
+
flatForMs: 2500,
|
|
96
|
+
longestHealthyFlatMs: 3000
|
|
97
|
+
});
|
|
98
|
+
assert.equal(verdict.certain, false);
|
|
99
|
+
assert.equal(verdict.needMs, 3000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a pause longer than any this link has shown is a wedge", () => {
|
|
103
|
+
const verdict = wedgeIsCertain({
|
|
104
|
+
queuedBytes: 8 * MEGABYTE,
|
|
105
|
+
bytesPerSecond: 18 * MEGABYTE,
|
|
106
|
+
flatForMs: 3500,
|
|
107
|
+
longestHealthyFlatMs: 3000
|
|
108
|
+
});
|
|
109
|
+
assert.equal(verdict.certain, true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the quiet-probe rate cannot be what the queue is divided by", () => {
|
|
113
|
+
// With the browser's buffer full, the only traffic is the probe: three
|
|
114
|
+
// channels, a few dozen bytes, twice a second. Dividing 8 MB by that gives
|
|
115
|
+
// six hours, and the wedge would never be called. The BEST rate seen is what
|
|
116
|
+
// the watcher keeps, so this case must not arise — pinned here as the
|
|
117
|
+
// arithmetic that made it matter.
|
|
118
|
+
const wrong = wedgeIsCertain({
|
|
119
|
+
queuedBytes: 8 * MEGABYTE,
|
|
120
|
+
bytesPerSecond: 360,
|
|
121
|
+
flatForMs: 60_000
|
|
122
|
+
});
|
|
123
|
+
assert.equal(wrong.certain, false);
|
|
124
|
+
assert.ok(wrong.needMs > 6 * 60 * 60 * 1000 - 1);
|
|
125
|
+
const right = wedgeIsCertain({
|
|
126
|
+
queuedBytes: 8 * MEGABYTE,
|
|
127
|
+
bytesPerSecond: 18 * MEGABYTE,
|
|
128
|
+
flatForMs: 60_000
|
|
129
|
+
});
|
|
130
|
+
assert.equal(right.certain, true);
|
|
131
|
+
});
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The second tier: pieces that no longer fit in memory.
|
|
3
|
-
*
|
|
4
|
-
* One sparse file per torrent, a piece at `index * chunkLength`. Not the
|
|
5
|
-
* torrent's real files — nobody reads those directly; playback goes through
|
|
6
|
-
* `/stream`, and the data is discarded when the torrent goes idle. A single
|
|
7
|
-
* file by piece index keeps the mapping arithmetic instead of bookkeeping, and
|
|
8
|
-
* sparseness means the untouched gaps cost nothing.
|
|
9
|
-
*
|
|
10
|
-
* Reads take a destination buffer rather than returning a fresh one. That is
|
|
11
|
-
* not a style preference — measured on the field host, an 8 MB piece costs
|
|
12
|
-
* 22.08 ms via `readFile`, which allocates, and **7.63 ms** read into a buffer
|
|
13
|
-
* we already hold. Two thirds of the apparent "disk" cost was allocation.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import fs from "node:fs/promises";
|
|
17
|
-
import { constants } from "node:fs";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
|
|
20
|
-
export class DiskTier {
|
|
21
|
-
#filePath;
|
|
22
|
-
#chunkLength;
|
|
23
|
-
/** @type {import("node:fs/promises").FileHandle | null} */
|
|
24
|
-
#handle = null;
|
|
25
|
-
/** Piece indices known to be on disk. */
|
|
26
|
-
#stored = new Set();
|
|
27
|
-
/** @type {Promise<void> | null} */
|
|
28
|
-
#opening = null;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @param {object} params
|
|
32
|
-
* @param {string} params.directory - Where the backing file lives.
|
|
33
|
-
* @param {string} params.name - File name, unique per torrent.
|
|
34
|
-
* @param {number} params.chunkLength - Piece size; fixes the stride on disk.
|
|
35
|
-
*/
|
|
36
|
-
constructor({ directory, name, chunkLength }) {
|
|
37
|
-
this.#filePath = path.join(directory, name);
|
|
38
|
-
this.#chunkLength = chunkLength;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Where the backing file lives, for logging and cleanup. */
|
|
42
|
-
get filePath() {
|
|
43
|
-
return this.#filePath;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** How many pieces are currently held on disk. */
|
|
47
|
-
get size() {
|
|
48
|
-
return this.#stored.size;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Open the backing file, creating it and its directory if needed.
|
|
53
|
-
*
|
|
54
|
-
* Concurrent callers share one open — several evictions can start at once.
|
|
55
|
-
*
|
|
56
|
-
* @returns {Promise<import("node:fs/promises").FileHandle>}
|
|
57
|
-
*/
|
|
58
|
-
async #open() {
|
|
59
|
-
if (this.#handle) {
|
|
60
|
-
return this.#handle;
|
|
61
|
-
}
|
|
62
|
-
if (!this.#opening) {
|
|
63
|
-
this.#opening = (async () => {
|
|
64
|
-
await fs.mkdir(path.dirname(this.#filePath), { recursive: true });
|
|
65
|
-
// Read/write, created if absent — NOT append mode. Under `a+` POSIX
|
|
66
|
-
// ignores the position on every write and puts the bytes at the end of
|
|
67
|
-
// the file, so pieces would pile up in arrival order and each read
|
|
68
|
-
// would return whichever piece happened to land at that offset.
|
|
69
|
-
this.#handle = await fs.open(this.#filePath, constants.O_RDWR | constants.O_CREAT);
|
|
70
|
-
})();
|
|
71
|
-
}
|
|
72
|
-
await this.#opening;
|
|
73
|
-
if (!this.#handle) {
|
|
74
|
-
throw new Error(`Could not open the piece file at ${this.#filePath}.`);
|
|
75
|
-
}
|
|
76
|
-
return this.#handle;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* @param {number} index
|
|
81
|
-
* @returns {boolean}
|
|
82
|
-
*/
|
|
83
|
-
has(index) {
|
|
84
|
-
return this.#stored.has(index);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Write a piece out.
|
|
89
|
-
*
|
|
90
|
-
* @param {number} index
|
|
91
|
-
* @param {Uint8Array} bytes
|
|
92
|
-
* @returns {Promise<void>}
|
|
93
|
-
*/
|
|
94
|
-
async write(index, bytes) {
|
|
95
|
-
const handle = await this.#open();
|
|
96
|
-
await handle.write(bytes, 0, bytes.length, index * this.#chunkLength);
|
|
97
|
-
this.#stored.add(index);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Read a piece back into a buffer the caller already owns.
|
|
102
|
-
*
|
|
103
|
-
* @param {number} index
|
|
104
|
-
* @param {Uint8Array} target - Destination; its length is what gets read.
|
|
105
|
-
* @returns {Promise<number>} Bytes read.
|
|
106
|
-
*/
|
|
107
|
-
async read(index, target) {
|
|
108
|
-
if (!this.#stored.has(index)) {
|
|
109
|
-
throw new Error(`Piece ${index} is not on disk.`);
|
|
110
|
-
}
|
|
111
|
-
const handle = await this.#open();
|
|
112
|
-
const { bytesRead } = await handle.read(target, 0, target.length, index * this.#chunkLength);
|
|
113
|
-
return bytesRead;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Forget a piece. The bytes stay on disk until the file is removed — there is
|
|
118
|
-
* nothing to gain from punching them out, since the file is discarded whole.
|
|
119
|
-
*
|
|
120
|
-
* @param {number} index
|
|
121
|
-
* @returns {void}
|
|
122
|
-
*/
|
|
123
|
-
forget(index) {
|
|
124
|
-
this.#stored.delete(index);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Close the file, leaving its contents in place.
|
|
129
|
-
*
|
|
130
|
-
* @returns {Promise<void>}
|
|
131
|
-
*/
|
|
132
|
-
async close() {
|
|
133
|
-
const handle = this.#handle;
|
|
134
|
-
this.#handle = null;
|
|
135
|
-
this.#opening = null;
|
|
136
|
-
if (handle) {
|
|
137
|
-
await handle.close();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Close and delete the backing file.
|
|
143
|
-
*
|
|
144
|
-
* @returns {Promise<void>}
|
|
145
|
-
*/
|
|
146
|
-
async destroy() {
|
|
147
|
-
await this.close();
|
|
148
|
-
this.#stored.clear();
|
|
149
|
-
await fs.rm(this.#filePath, { force: true });
|
|
150
|
-
}
|
|
151
|
-
}
|