@torrent-tv/proxy 2.27.0 → 2.29.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 +9 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +49 -4
- package/services/torrent-worker/fastest-wires.js +121 -0
- package/services/torrent-worker/piece-reader.js +41 -1
- package/test/cut-times-timeline.test.js +64 -0
- package/test/fastest-wires.test.js +121 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.29.0
|
|
2
|
+
|
|
3
|
+
- **New**: A piece a reader is blocked on is handed to the fastest peers that hold it. Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times in two minutes, 1.0-4.5 s each, on pieces a median of five peers already had. A block belongs to exactly one wire, so the read ends when the SLOWEST holder delivers, and `critical()` only lets the library take a block from a slow wire when its own picker happens to visit an idle one. This asks for it deliberately: when the wait starts, and again on the sampling tick that already runs while it lasts, the piece is pushed onto the three fastest unchoked holders through the library's own request entry with hotswap enabled. Nothing is duplicated — the library moves a block to a wire at least twice as fast, which bounds how often it can move at all. A refusal is counted rather than ignored (a full pipeline, or nothing reservable even with hotswap, means the piece waits on the wire and not on the picker), and a build that offers no such entry says so instead of failing silently. The wait line now reports `steered onto N of M holders`, so the next session says by number whether the tail shortened.
|
|
4
|
+
|
|
5
|
+
## 2.28.0
|
|
6
|
+
|
|
7
|
+
- **Fix**: The playlist and the media agree again, and the container's keyframe table was never at fault. On a file whose first timestamp is 2.002 s, the copied picture was asked to cut at 808.808 s on the 0-based grid and cut at 806.806 s — exactly the container's start time early, because that branch keeps the source's own timestamps and re-labels the output afterwards, so a cut list stated in 0-based terms is applied 2 s away from where it means. The soundtrack, re-encoded and on the other branch, cut where it was asked. The two then wrote different values into the shared boundary table and corrected each other for the whole session (#202: 808.808 → 806.806 → 808.750 → …), the playlist drifted a whole segment from the media, and the player refetched fragments it could not place. The cut list is now stated in the source's terms on that branch, which is the same shift the seek on it already applies.
|
|
8
|
+
- **Chore**: Which timeline a session works on is answered by one exported predicate instead of two expressions that could disagree — and their disagreement is exactly what desynced picture from sound. Pinned by `test/cut-times-timeline.test.js`, with the field numbers in its header.
|
|
9
|
+
|
|
1
10
|
## 2.27.0
|
|
2
11
|
|
|
3
12
|
- **Fix**: Picture and sound now begin a run at the same instant. They were asked for the same time and landed in different places: a copied picture may begin only at a real keyframe and may not begin before the time asked for — that content belongs to the previous segment — so it moves FORWARD to the next keyframe, by up to the keyframe spacing (0.58-2.96 s measured on the field file); a soundtrack has no keyframes and begins exactly where asked, to within one audio frame. So after every seek the two runs of one film began up to three seconds apart. The picture's true start is measured from the piece it produces, and that measurement now moves every other member of the family whose run begins at the same boundary. Restarted at the boundary rather than seeked to the time, deliberately: a seek decides by segment index, finds the run already begins there and answers "already within the running encode" — true about the index and false about the instant, which is why the first version of this fix moved nothing at all.
|
package/package.json
CHANGED
|
@@ -1019,6 +1019,29 @@ export function ffmpegSeconds(value) {
|
|
|
1019
1019
|
* @param {{ useKeyframeGrid: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
|
|
1020
1020
|
* @returns {number[]}
|
|
1021
1021
|
*/
|
|
1022
|
+
/**
|
|
1023
|
+
* Which timeline a session's own ffmpeg works on.
|
|
1024
|
+
*
|
|
1025
|
+
* True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
|
|
1026
|
+
* output is re-labelled 0-based. Everything handed to the muxer is therefore
|
|
1027
|
+
* stated in the source's terms, and everything read back out of a produced
|
|
1028
|
+
* piece is 0-based.
|
|
1029
|
+
*
|
|
1030
|
+
* False — the re-encode branch: the output is labelled from the run's start on
|
|
1031
|
+
* the 0-based timeline, and the muxer is addressed in those same terms.
|
|
1032
|
+
*
|
|
1033
|
+
* One predicate for both callers, because the two used to answer it separately
|
|
1034
|
+
* and a disagreement between them is exactly what desynced picture from sound.
|
|
1035
|
+
*
|
|
1036
|
+
* @param {{ audioOnly?: boolean, cutGrid?: string, transcodeVideo?: boolean }} session
|
|
1037
|
+
* @returns {boolean}
|
|
1038
|
+
*/
|
|
1039
|
+
export function onKeyframeGridFor(session) {
|
|
1040
|
+
return session?.audioOnly === true
|
|
1041
|
+
? session?.cutGrid === "keyframe"
|
|
1042
|
+
: session?.transcodeVideo !== true;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1022
1045
|
export function computeSegmentBoundaries({ useKeyframeGrid, durationSeconds, segDur, keyframeTimes, startTime }) {
|
|
1023
1046
|
const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
|
|
1024
1047
|
const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
|
|
@@ -3541,9 +3564,33 @@ export class HlsSessionManager {
|
|
|
3541
3564
|
// writes no self-contained pieces, so nothing could read a true start and
|
|
3542
3565
|
// segments were stamped with times the file does not have — the 4.17 s
|
|
3543
3566
|
// speech-against-subtitles drift, back again.
|
|
3544
|
-
const
|
|
3567
|
+
const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
|
|
3545
3568
|
? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
|
|
3546
3569
|
: null;
|
|
3570
|
+
// On the COPY branch the muxer decides its cuts against the source's own
|
|
3571
|
+
// timestamps, not against the labels we ask it to write. That branch keeps
|
|
3572
|
+
// the source's timestamps (`-copyts`) and re-labels the output 0-based with
|
|
3573
|
+
// `-output_ts_offset -sourceStartTime`; the cut list, being applied before
|
|
3574
|
+
// that relabelling, must therefore be stated in the SOURCE's terms.
|
|
3575
|
+
//
|
|
3576
|
+
// Measured 2026-08-17, and this is the whole of the trouble: asked to cut
|
|
3577
|
+
// at 808.808 s on the 0-based grid, ffmpeg cut at 806.806 s — exactly
|
|
3578
|
+
// `sourceStartTime` (2.002 s) early, and 806.806 s is itself a keyframe the
|
|
3579
|
+
// container's table names, which is why every "disagreement" landed on
|
|
3580
|
+
// another real keyframe. The soundtrack, which is re-encoded and takes the
|
|
3581
|
+
// other branch, cut where it was asked. The two then told the shared
|
|
3582
|
+
// boundary table different things and corrected each other back and forth
|
|
3583
|
+
// for the whole session (#202: 808.808 → 806.806 → 808.750 → …), so the
|
|
3584
|
+
// playlist and the media drifted apart by a whole segment and the player
|
|
3585
|
+
// refetched what it could not place.
|
|
3586
|
+
//
|
|
3587
|
+
// Nothing here is a guess about ffmpeg's semantics: the shift is the same
|
|
3588
|
+
// one the seek already applies on this branch (`seekSeconds = startSeconds
|
|
3589
|
+
// + sourceStartTime`), and the field measurement above is what says the
|
|
3590
|
+
// cuts needed it too.
|
|
3591
|
+
const cutTimes = gridCutTimes && onKeyframeGridFor(session) && sourceStartTime !== 0
|
|
3592
|
+
? gridCutTimes.map((time) => Number((time + sourceStartTime).toFixed(6)))
|
|
3593
|
+
: gridCutTimes;
|
|
3547
3594
|
|
|
3548
3595
|
// A second chance for a predecessor that survived the escalation above —
|
|
3549
3596
|
// the first block is the one that does the work. Its exit is ignored
|
|
@@ -3641,9 +3688,7 @@ export class HlsSessionManager {
|
|
|
3641
3688
|
// the copy branch: `-copyts` and a shift by the container's start time,
|
|
3642
3689
|
// against a picture labelled from zero. The two would be offset by
|
|
3643
3690
|
// `sourceStartTime` for the whole file.
|
|
3644
|
-
const onKeyframeGrid = session
|
|
3645
|
-
? session.cutGrid === "keyframe"
|
|
3646
|
-
: !session.transcodeVideo;
|
|
3691
|
+
const onKeyframeGrid = onKeyframeGridFor(session);
|
|
3647
3692
|
if (!onKeyframeGrid) {
|
|
3648
3693
|
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
3649
3694
|
// segment grid; relabel output onto the original timeline so segment N
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Put the piece a reader is blocked on onto the fastest wires that hold
|
|
3
|
+
* it.
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed
|
|
6
|
+
* at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times
|
|
7
|
+
* in two minutes, 1.0-4.5 s each. So the shortage is not bandwidth. The piece
|
|
8
|
+
* that blocked had been requested from a median of five peers, and what set the
|
|
9
|
+
* tail was WHICH wire held the last outstanding block: a block is reserved for
|
|
10
|
+
* exactly one wire, and the read finishes when the slowest holder delivers.
|
|
11
|
+
*
|
|
12
|
+
* What the library already does, and what it does not: `torrent.critical()`
|
|
13
|
+
* (which the reader already sets over its window) enables HOTSWAP — an idle
|
|
14
|
+
* wire may take a block away from the SLOWEST holder. It does not duplicate:
|
|
15
|
+
* `piece.reserve()` returns -1 once every block is spoken for, and the
|
|
16
|
+
* end-game that would ask a second peer for the same block is commented out in
|
|
17
|
+
* `webtorrent/lib/torrent.js`. Hotswap therefore fires only when the library's
|
|
18
|
+
* own picker happens to visit an idle wire while our piece is critical.
|
|
19
|
+
*
|
|
20
|
+
* This asks for it on purpose, and chooses who: the wires that are unchoked,
|
|
21
|
+
* hold the piece and are measurably fastest are handed the piece through the
|
|
22
|
+
* same entry the library's picker uses, with hotswap enabled. Nothing is
|
|
23
|
+
* duplicated and no protocol rule is bent — the block moves to a faster holder
|
|
24
|
+
* instead of staying with whoever got it first.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The library's own request entry. Internal, so its absence must be noticed
|
|
29
|
+
* rather than swallowed: without it this lever silently does nothing.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} torrent
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function canPlaceRequests(torrent) {
|
|
35
|
+
return typeof torrent?._request === "function";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wires that could deliver this piece right now, fastest first.
|
|
40
|
+
*
|
|
41
|
+
* Excluded, each for its own reason: a wire that is choking us cannot be asked
|
|
42
|
+
* at all; one that does not hold the piece has nothing to give; a destroyed one
|
|
43
|
+
* is a corpse. Speed is the library's own measurement over its own window, so
|
|
44
|
+
* nothing here needs a history of its own.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} torrent
|
|
47
|
+
* @param {number} pieceIndex
|
|
48
|
+
* @returns {object[]}
|
|
49
|
+
*/
|
|
50
|
+
export function wiresForPiece(torrent, pieceIndex) {
|
|
51
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
52
|
+
const usable = [];
|
|
53
|
+
for (const wire of wires) {
|
|
54
|
+
if (!wire || wire.destroyed || wire.peerChoking) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (wire.peerPieces?.get?.(pieceIndex) !== true) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
usable.push(wire);
|
|
61
|
+
}
|
|
62
|
+
return usable.sort((left, right) => speedOf(right) - speedOf(left));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A wire's measured download speed in bytes per second, or 0 when it has not
|
|
67
|
+
* been measured. Wrapped because `downloadSpeed` is a method on the wire and a
|
|
68
|
+
* throw from a destroyed one must not take the caller with it.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} wire
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
function speedOf(wire) {
|
|
74
|
+
try {
|
|
75
|
+
const speed = wire?.downloadSpeed?.();
|
|
76
|
+
return Number.isFinite(speed) ? speed : 0;
|
|
77
|
+
} catch {
|
|
78
|
+
// A wire that cannot say how fast it is ranks last, which is the honest
|
|
79
|
+
// answer — and the caller's own log line reports how many were asked.
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ask the fastest holders of `pieceIndex` for it.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} torrent
|
|
88
|
+
* @param {number} pieceIndex
|
|
89
|
+
* @param {number} [limit] - How many wires to push it onto.
|
|
90
|
+
* @returns {{ asked: number, considered: number, fastestBytesPerSecond: number }}
|
|
91
|
+
* `asked` counts requests the library actually placed: it refuses when a
|
|
92
|
+
* wire's pipeline is full or when nothing can be reserved even with hotswap,
|
|
93
|
+
* and that refusal is information — a piece nobody can be asked for is
|
|
94
|
+
* waiting on the wire, not on the picker.
|
|
95
|
+
*/
|
|
96
|
+
export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
|
|
97
|
+
if (!canPlaceRequests(torrent) || !Number.isInteger(pieceIndex) || pieceIndex < 0) {
|
|
98
|
+
return { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
|
|
99
|
+
}
|
|
100
|
+
const candidates = wiresForPiece(torrent, pieceIndex);
|
|
101
|
+
let asked = 0;
|
|
102
|
+
for (const wire of candidates.slice(0, Math.max(1, limit))) {
|
|
103
|
+
try {
|
|
104
|
+
// `true` is hotswap: if every block is reserved, take one from the
|
|
105
|
+
// slowest holder. That is the whole point — the reader is blocked
|
|
106
|
+
// precisely because a slow holder has one.
|
|
107
|
+
if (torrent._request(wire, pieceIndex, true) === true) {
|
|
108
|
+
asked += 1;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Internal call: report it once per attempt rather than letting the lever
|
|
112
|
+
// fail in silence.
|
|
113
|
+
throw new Error(`could not place a request for piece ${pieceIndex}: ${error?.message ?? error}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
asked,
|
|
118
|
+
considered: candidates.length,
|
|
119
|
+
fastestBytesPerSecond: candidates.length > 0 ? speedOf(candidates[0]) : 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
24
24
|
import { logger } from "../../utils/logger.js";
|
|
25
|
+
import { askFastestWiresFor, canPlaceRequests } from "./fastest-wires.js";
|
|
25
26
|
|
|
26
27
|
/** Only waits at least this long are reported; sequential reading stays silent. */
|
|
27
28
|
const PIECE_WAIT_LOG_MS = 1_000;
|
|
@@ -438,6 +439,35 @@ export async function* readFragments({
|
|
|
438
439
|
}
|
|
439
440
|
|
|
440
441
|
const waitStartedAt = Date.now();
|
|
442
|
+
// The reader is blocked, so this piece is now the only thing that matters
|
|
443
|
+
// on this torrent: hand it to the fastest wires that hold it. A block is
|
|
444
|
+
// reserved for exactly one wire, and the read ends when the slowest
|
|
445
|
+
// holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
|
|
446
|
+
// of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
|
|
447
|
+
// minutes, on pieces five peers already had.
|
|
448
|
+
let pushed = { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
|
|
449
|
+
const pushToFastest = () => {
|
|
450
|
+
try {
|
|
451
|
+
const result = askFastestWiresFor(torrent, pieceIndex);
|
|
452
|
+
pushed = {
|
|
453
|
+
asked: pushed.asked + result.asked,
|
|
454
|
+
considered: result.considered,
|
|
455
|
+
fastestBytesPerSecond: result.fastestBytesPerSecond
|
|
456
|
+
};
|
|
457
|
+
} catch (error) {
|
|
458
|
+
// The entry is internal to the library; if a version changes it, this
|
|
459
|
+
// lever stops working and that must be visible rather than silent.
|
|
460
|
+
logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
if (canPlaceRequests(torrent)) {
|
|
464
|
+
pushToFastest();
|
|
465
|
+
} else {
|
|
466
|
+
logger.warn(
|
|
467
|
+
"piece-reader: this webtorrent build offers no way to place a request; " +
|
|
468
|
+
"the blocked piece cannot be steered onto a faster peer"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
441
471
|
// Sampled while waiting rather than after: once the piece lands, nothing
|
|
442
472
|
// is outstanding on it any more and every count reads zero.
|
|
443
473
|
let supply = null;
|
|
@@ -446,6 +476,9 @@ export async function* readFragments({
|
|
|
446
476
|
if (!supply || sample.blocks > supply.blocks) {
|
|
447
477
|
supply = sample;
|
|
448
478
|
}
|
|
479
|
+
// Wires come and go, and their speeds change: a holder that was slow a
|
|
480
|
+
// moment ago may now be the fastest one available.
|
|
481
|
+
pushToFastest();
|
|
449
482
|
}, 500);
|
|
450
483
|
try {
|
|
451
484
|
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
@@ -482,7 +515,14 @@ export async function* readFragments({
|
|
|
482
515
|
(supply
|
|
483
516
|
? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
|
|
484
517
|
`${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
|
|
485
|
-
: "no sample taken")
|
|
518
|
+
: "no sample taken") +
|
|
519
|
+
// What WE did about it, so the next session says whether steering
|
|
520
|
+
// the piece onto faster holders shortens the tail — by number
|
|
521
|
+
// rather than by impression.
|
|
522
|
+
`; steered onto ${pushed.asked} of ${pushed.considered} holders` +
|
|
523
|
+
(pushed.fastestBytesPerSecond > 0
|
|
524
|
+
? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
|
|
525
|
+
: "")
|
|
486
526
|
);
|
|
487
527
|
}
|
|
488
528
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The cut list must be stated on the timeline the muxer decides against.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-17 on a Matroska whose first timestamp is 2.002 s. Asked to
|
|
5
|
+
* cut at 808.808 s on the 0-based grid, the copied picture cut at 806.806 s —
|
|
6
|
+
* exactly the container's start time early, and itself a keyframe the file's
|
|
7
|
+
* own table names, which is why every "disagreement" landed on a real keyframe
|
|
8
|
+
* and the table looked like it was lying. The soundtrack, re-encoded and on the
|
|
9
|
+
* other branch, cut where it was asked. The two then wrote different values
|
|
10
|
+
* into the shared boundary table and corrected each other back and forth
|
|
11
|
+
* (#202: 808.808 → 806.806 → 808.750), so the playlist and the media drifted
|
|
12
|
+
* apart by a whole segment.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
|
|
18
|
+
import { onKeyframeGridFor } from "../services/hls-session-manager.js";
|
|
19
|
+
|
|
20
|
+
test("a copied picture works on the source's timeline", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
onKeyframeGridFor({ transcodeVideo: false }),
|
|
23
|
+
true,
|
|
24
|
+
"video copied means the source's own timestamps are kept"
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a re-encoded picture works on the 0-based timeline", () => {
|
|
29
|
+
assert.equal(onKeyframeGridFor({ transcodeVideo: true }), false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a soundtrack follows the grid it was cut on, not its own encoding", () => {
|
|
33
|
+
// A rendition is re-encoded by definition, so asking `transcodeVideo` about
|
|
34
|
+
// it answers nothing. What decides its timeline is the grid it shares with
|
|
35
|
+
// the picture it plays with.
|
|
36
|
+
assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe" }), true);
|
|
37
|
+
assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "uniform" }), false);
|
|
38
|
+
assert.equal(
|
|
39
|
+
onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe", transcodeVideo: true }),
|
|
40
|
+
true,
|
|
41
|
+
"its own encoding must not decide this — that disagreement is the defect"
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("one predicate answers for every caller", () => {
|
|
46
|
+
// The two callers used to answer this separately, in two expressions that
|
|
47
|
+
// could drift apart. A session put through both must get one answer.
|
|
48
|
+
for (const session of [
|
|
49
|
+
{ transcodeVideo: false },
|
|
50
|
+
{ transcodeVideo: true },
|
|
51
|
+
{ audioOnly: true, cutGrid: "keyframe" },
|
|
52
|
+
{ audioOnly: true, cutGrid: "uniform" },
|
|
53
|
+
{}
|
|
54
|
+
]) {
|
|
55
|
+
assert.equal(onKeyframeGridFor(session), onKeyframeGridFor({ ...session }));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a session that says nothing is treated as copying", () => {
|
|
60
|
+
// The default matters: an absent `transcodeVideo` means the picture is
|
|
61
|
+
// copied, which is the branch that needs the shift.
|
|
62
|
+
assert.equal(onKeyframeGridFor({}), true);
|
|
63
|
+
assert.equal(onKeyframeGridFor(null), true);
|
|
64
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Choosing who is asked for the piece a reader is blocked on.
|
|
3
|
+
*
|
|
4
|
+
* The measurement behind it (2026-08-17): a fivefold bandwidth surplus and 47
|
|
5
|
+
* blocking waits in two minutes, on pieces a median of five peers already had.
|
|
6
|
+
* A block belongs to one wire, so the read ends when the SLOWEST holder
|
|
7
|
+
* delivers — which makes "who holds it" the thing worth deciding.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import { askFastestWiresFor, canPlaceRequests, wiresForPiece } from "../services/torrent-worker/fastest-wires.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ speed: number, has?: boolean, choking?: boolean, destroyed?: boolean }} options
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
function wire({ speed, has = true, choking = false, destroyed = false }) {
|
|
20
|
+
return {
|
|
21
|
+
destroyed,
|
|
22
|
+
peerChoking: choking,
|
|
23
|
+
peerPieces: { get: () => has },
|
|
24
|
+
downloadSpeed: () => speed
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object[]} wires
|
|
30
|
+
* @returns {{ torrent: object, asked: Array<{ speed: number, hotswap: boolean }> }}
|
|
31
|
+
*/
|
|
32
|
+
function torrentWith(wires, { refuse = false } = {}) {
|
|
33
|
+
/** @type {Array<{ speed: number, hotswap: boolean }>} */
|
|
34
|
+
const asked = [];
|
|
35
|
+
const torrent = {
|
|
36
|
+
wires,
|
|
37
|
+
_request(target, _index, hotswap) {
|
|
38
|
+
asked.push({ speed: target.downloadSpeed(), hotswap });
|
|
39
|
+
return !refuse;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return { torrent, asked };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("the fastest holders are asked first", () => {
|
|
46
|
+
const { torrent, asked } = torrentWith([
|
|
47
|
+
wire({ speed: 100_000 }),
|
|
48
|
+
wire({ speed: 900_000 }),
|
|
49
|
+
wire({ speed: 400_000 })
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const result = askFastestWiresFor(torrent, 42);
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(
|
|
55
|
+
asked.map((entry) => entry.speed),
|
|
56
|
+
[900_000, 400_000, 100_000],
|
|
57
|
+
"fastest first — the read ends when the slowest holder delivers"
|
|
58
|
+
);
|
|
59
|
+
assert.equal(result.asked, 3);
|
|
60
|
+
assert.equal(result.fastestBytesPerSecond, 900_000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hotswap is always on, because that is the whole point", () => {
|
|
64
|
+
// The reader is blocked precisely because every block is reserved and one of
|
|
65
|
+
// them sits with a slow wire. Without hotswap the library answers "nothing to
|
|
66
|
+
// reserve" and the lever does nothing at all.
|
|
67
|
+
const { torrent, asked } = torrentWith([wire({ speed: 10 })]);
|
|
68
|
+
askFastestWiresFor(torrent, 7);
|
|
69
|
+
assert.deepEqual(asked.map((entry) => entry.hotswap), [true]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a wire that cannot deliver is not asked", () => {
|
|
73
|
+
const { torrent, asked } = torrentWith([
|
|
74
|
+
wire({ speed: 900_000, choking: true }),
|
|
75
|
+
wire({ speed: 800_000, has: false }),
|
|
76
|
+
wire({ speed: 700_000, destroyed: true }),
|
|
77
|
+
wire({ speed: 1_000 })
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const result = askFastestWiresFor(torrent, 3);
|
|
81
|
+
|
|
82
|
+
assert.equal(asked.length, 1, "a choking, an absent and a dead wire are all unusable");
|
|
83
|
+
assert.equal(result.considered, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a refusal is counted as a refusal", () => {
|
|
87
|
+
// The library refuses when a wire's pipeline is full or nothing can be
|
|
88
|
+
// reserved even with hotswap. That is information: the piece is waiting on
|
|
89
|
+
// the wire, not on the picker.
|
|
90
|
+
const { torrent } = torrentWith([wire({ speed: 500 }), wire({ speed: 400 })], { refuse: true });
|
|
91
|
+
const result = askFastestWiresFor(torrent, 11);
|
|
92
|
+
assert.equal(result.asked, 0);
|
|
93
|
+
assert.equal(result.considered, 2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a build without the request entry is reported, not silently skipped", () => {
|
|
97
|
+
assert.equal(canPlaceRequests({ wires: [] }), false);
|
|
98
|
+
assert.equal(canPlaceRequests({ wires: [], _request: () => true }), true);
|
|
99
|
+
const result = askFastestWiresFor({ wires: [wire({ speed: 1 })] }, 1);
|
|
100
|
+
assert.deepEqual(result, { asked: 0, considered: 0, fastestBytesPerSecond: 0 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a wire that cannot say how fast it is ranks last rather than throwing", () => {
|
|
104
|
+
const mute = wire({ speed: 0 });
|
|
105
|
+
mute.downloadSpeed = () => {
|
|
106
|
+
throw new Error("destroyed");
|
|
107
|
+
};
|
|
108
|
+
const ranked = wiresForPiece({ wires: [mute, wire({ speed: 5 })] }, 0);
|
|
109
|
+
assert.equal(ranked.length, 2);
|
|
110
|
+
assert.equal(ranked[0].downloadSpeed(), 5);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a throw from the library is raised, never swallowed", () => {
|
|
114
|
+
const torrent = {
|
|
115
|
+
wires: [wire({ speed: 1 })],
|
|
116
|
+
_request() {
|
|
117
|
+
throw new Error("internal");
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
assert.throws(() => askFastestWiresFor(torrent, 5), /could not place a request for piece 5/);
|
|
121
|
+
});
|