@torrent-tv/proxy 2.98.14 → 2.98.16
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/package.json +1 -1
- package/services/encode/host-load.js +28 -0
- package/services/encode/quality/HostLoad.js +37 -23
- package/services/encode/quality/encoder-cpu.js +66 -0
- package/services/media/ContainerOrchestrator.js +13 -2
- package/services/media/container/AviContainer.js +34 -0
- package/services/server/wire-outputs.js +2 -1
- package/services/torrent/download/SwarmSelection.js +19 -1
- package/services/torrent/piece-timing.js +19 -4
- package/services/torrent/torrent-pool.js +22 -0
- package/services/torrent/worker/fastest-wires.js +62 -31
- package/services/torrent/worker/tail-repeat.js +154 -0
package/package.json
CHANGED
|
@@ -92,6 +92,34 @@ export async function readProcessCpuSeconds(pid) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The CPU time every child of this process that has ended has used, from the
|
|
97
|
+
* `cutime` and `cstime` of `/proc/self/stat`: the kernel adds a child's time
|
|
98
|
+
* there when the child is waited for, which Node does as soon as it exits.
|
|
99
|
+
*
|
|
100
|
+
* What a process that started and ended between two readings of its own pid
|
|
101
|
+
* cost, which no reading of that pid can say: on Home Assistant a run of one
|
|
102
|
+
* piece started and ended between two readings 5 s apart, used 6.65 s of CPU,
|
|
103
|
+
* and both readings said `ffmpeg=0%` (r41, torrent-tv/meta#178).
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<number | null>} Seconds of CPU, or null where there is no `/proc`.
|
|
106
|
+
*/
|
|
107
|
+
export async function readEndedChildrenCpuSeconds() {
|
|
108
|
+
try {
|
|
109
|
+
const text = await readFile("/proc/self/stat", "utf8");
|
|
110
|
+
const afterComm = text.slice(text.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
111
|
+
// After the comm and state fields, cutime is index 13 and cstime index 14.
|
|
112
|
+
const cutime = Number(afterComm[13]);
|
|
113
|
+
const cstime = Number(afterComm[14]);
|
|
114
|
+
if (!Number.isFinite(cutime) || !Number.isFinite(cstime)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return (cutime + cstime) / CLOCK_TICKS_PER_SECOND;
|
|
118
|
+
} catch {
|
|
119
|
+
return null; // not Linux, or /proc is not mounted
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
/**
|
|
96
124
|
* Is this still the machine the benchmarks were taken on: its clock and its
|
|
97
125
|
* temperature.
|
|
@@ -11,6 +11,7 @@ import os from "node:os";
|
|
|
11
11
|
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
12
12
|
import { logger } from "../../../utils/logger.js";
|
|
13
13
|
import { availableShareFrom } from "../available-share.js";
|
|
14
|
+
import { encoderCpuBetween } from "./encoder-cpu.js";
|
|
14
15
|
import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
|
|
15
16
|
import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
|
|
16
17
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
|
|
@@ -532,17 +533,23 @@ export class HostLoad {
|
|
|
532
533
|
* the only thing that would have taken the next reading.
|
|
533
534
|
*/
|
|
534
535
|
async #learnIdleAvailability(watched, suspended) {
|
|
535
|
-
const system =
|
|
536
|
-
|
|
536
|
+
const [system, endedChildren] = await Promise.all([
|
|
537
|
+
this.#host.readSystemCpu?.() ?? null,
|
|
538
|
+
this.#host.readEndedChildrenCpuSeconds?.() ?? null
|
|
539
|
+
]);
|
|
540
|
+
const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
|
|
537
541
|
const previous = this.#idleAvailabilitySample;
|
|
538
542
|
this.#idleAvailabilitySample = sample;
|
|
539
543
|
if (previous === null) {
|
|
540
544
|
this.hostAvailability = availableShareFrom({});
|
|
541
545
|
return;
|
|
542
546
|
}
|
|
547
|
+
// Nothing runs at either reading, and a run may still have run between
|
|
548
|
+
// them: what it used is in the ended-children time (`encoder-cpu.js`).
|
|
549
|
+
const encoders = encoderCpuBetween(previous, sample);
|
|
543
550
|
const share = this.#host.shareOfMachine(
|
|
544
551
|
{ takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
|
|
545
|
-
{ takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
|
|
552
|
+
{ takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? 0, system: sample.system }
|
|
546
553
|
);
|
|
547
554
|
const proxyShare = this.#setAvailability(share, previous, sample);
|
|
548
555
|
// Said while something is watched, so what is free while every encoder
|
|
@@ -559,7 +566,7 @@ export class HostLoad {
|
|
|
559
566
|
* and the processor share divide; `free` is what nothing used over the
|
|
560
567
|
* interval. Unknown stays `n/a`, never zero.
|
|
561
568
|
*/
|
|
562
|
-
async #sayMachine(share, proxyShare, running, suspended) {
|
|
569
|
+
async #sayMachine(share, proxyShare, running, suspended, incomplete = false) {
|
|
563
570
|
const machine = await this.#host.readMachineState();
|
|
564
571
|
// HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
|
|
565
572
|
// and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
|
|
@@ -574,7 +581,8 @@ export class HostLoad {
|
|
|
574
581
|
const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
|
|
575
582
|
const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
|
|
576
583
|
logger.info(
|
|
577
|
-
`host-load: ffmpeg=${asPercent(share.processShare)}
|
|
584
|
+
`host-load: ffmpeg=${incomplete ? `at least ${asPercent(share.processShare)} (a run was not read; availability kept)` : asPercent(share.processShare)} ` +
|
|
585
|
+
`proxy=${asPercent(proxyShare)} ` +
|
|
578
586
|
`system=${asPercent(share.systemShare)} ` +
|
|
579
587
|
`iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
|
|
580
588
|
`temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
|
|
@@ -646,22 +654,28 @@ export class HostLoad {
|
|
|
646
654
|
// alone would print something like `ffmpeg=-598%`. Only pids present in
|
|
647
655
|
// BOTH readings are counted, so a process that came or went contributes
|
|
648
656
|
// nothing rather than a lie.
|
|
649
|
-
const
|
|
650
|
-
const [system, ...cpuReadings] = await Promise.all([
|
|
657
|
+
const runs = encoding.flatMap((session) => this.#host.liveRunsOf(session)).filter((run) => Number.isInteger(run.process?.pid));
|
|
658
|
+
const [system, endedChildren, ...cpuReadings] = await Promise.all([
|
|
651
659
|
this.#host.readSystemCpu(),
|
|
652
|
-
|
|
660
|
+
this.#host.readEndedChildrenCpuSeconds?.() ?? null,
|
|
661
|
+
...runs.map((run) => this.#host.readProcessCpuSeconds(run.process.pid))
|
|
653
662
|
]);
|
|
654
663
|
/** @type {Map<number, number>} */
|
|
655
664
|
const byPid = new Map();
|
|
656
|
-
|
|
665
|
+
/** @type {Map<number, number>} */
|
|
666
|
+
const startedAt = new Map();
|
|
667
|
+
runs.forEach((run, index) => {
|
|
657
668
|
const seconds = cpuReadings[index];
|
|
658
669
|
if (seconds !== null) {
|
|
659
|
-
byPid.set(pid, seconds);
|
|
670
|
+
byPid.set(run.process.pid, seconds);
|
|
671
|
+
startedAt.set(run.process.pid, run.startedAt);
|
|
660
672
|
}
|
|
661
673
|
});
|
|
662
674
|
const sample = {
|
|
663
675
|
takenAt: Date.now(),
|
|
664
676
|
byPid,
|
|
677
|
+
startedAt,
|
|
678
|
+
endedChildren,
|
|
665
679
|
system,
|
|
666
680
|
// The proxy's own CPU, across every thread: the torrent, the hashing, the
|
|
667
681
|
// piece store and the delivery. None of it is in the encode budget, and
|
|
@@ -674,20 +688,14 @@ export class HostLoad {
|
|
|
674
688
|
if (previous === null) {
|
|
675
689
|
return false; // the first reading is only something to compare against
|
|
676
690
|
}
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
for (const [pid, seconds] of sample.byPid) {
|
|
683
|
-
const before = previous.byPid?.get(pid);
|
|
684
|
-
if (before !== undefined && seconds >= before) {
|
|
685
|
-
encoderDelta = (encoderDelta ?? 0) + (seconds - before);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
691
|
+
// Every process counted once, including runs that started or ended between
|
|
692
|
+
// the readings (`encoder-cpu.js`). Unknown stays unknown: on a host with no
|
|
693
|
+
// `/proc` there are no readings at all, and the share is null rather than a
|
|
694
|
+
// confident zero.
|
|
695
|
+
const encoders = encoderCpuBetween(previous, sample);
|
|
688
696
|
const share = this.#host.shareOfMachine(
|
|
689
|
-
{ takenAt: previous.takenAt, processCpuSeconds:
|
|
690
|
-
{ takenAt: sample.takenAt, processCpuSeconds:
|
|
697
|
+
{ takenAt: previous.takenAt, processCpuSeconds: encoders === null ? null : 0, system: previous.system },
|
|
698
|
+
{ takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? null, system: sample.system }
|
|
691
699
|
);
|
|
692
700
|
if (share === null) {
|
|
693
701
|
this.hostAvailability = availableShareFrom({});
|
|
@@ -701,6 +709,12 @@ export class HostLoad {
|
|
|
701
709
|
// they stopped).
|
|
702
710
|
const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
|
|
703
711
|
const running = encoding.length - suspended;
|
|
712
|
+
if (encoders && !encoders.complete) {
|
|
713
|
+
// A lower bound of our own load is not a reading of what other work left
|
|
714
|
+
// us: the last availability stands, and the line says so.
|
|
715
|
+
await this.#sayMachine(share, null, running, suspended, true);
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
704
718
|
// Kept for the quality offer, which predicts from a benchmark taken on a
|
|
705
719
|
// QUIET host: the same reading that is printed here says how much of the
|
|
706
720
|
// machine a new encoder could actually have. Only what nobody has been
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The CPU this proxy's FFmpeg processes used between two readings of the
|
|
3
|
+
* machine (torrent-tv/meta#178).
|
|
4
|
+
*
|
|
5
|
+
* A process's own counter says what it used since it started, and is gone once
|
|
6
|
+
* it has ended. Counting only processes present in both readings left out every
|
|
7
|
+
* run that started or ended between them: on Home Assistant a run of one piece
|
|
8
|
+
* started and ended between two readings 5 s apart, used 6.65 s of CPU, and
|
|
9
|
+
* both readings said `ffmpeg=0%` (r41). The availability left to encoders is
|
|
10
|
+
* the machine's load less theirs, so an encoder left out made the machine look
|
|
11
|
+
* busier with other work than it was.
|
|
12
|
+
*
|
|
13
|
+
* So three parts, each counted once:
|
|
14
|
+
* 1. a process in both readings: what its counter moved;
|
|
15
|
+
* 2. a process started after the first reading: its whole counter;
|
|
16
|
+
* 3. a process that ended between them: what the kernel added to this
|
|
17
|
+
* process's ended-children time (`cutime + cstime`), less what the first
|
|
18
|
+
* reading already counted of it.
|
|
19
|
+
* A part that cannot be told — a process that was there but unread, an
|
|
20
|
+
* ended-children time that went back, no such time at all while a process
|
|
21
|
+
* ended — makes the answer incomplete: a lower bound, never a measured zero.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} EncoderReading
|
|
26
|
+
* @property {number} takenAt - Wall clock, ms.
|
|
27
|
+
* @property {Map<number, number>} byPid - CPU seconds of each live FFmpeg process read.
|
|
28
|
+
* @property {Map<number, number>} [startedAt] - When each of them was spawned, wall clock ms.
|
|
29
|
+
* @property {number | null} endedChildren - This process's ended-children CPU seconds.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {EncoderReading} previous
|
|
34
|
+
* @param {EncoderReading} sample
|
|
35
|
+
* @returns {{ seconds: number, complete: boolean } | null} Null when nothing at all was read.
|
|
36
|
+
*/
|
|
37
|
+
export function encoderCpuBetween(previous, sample) {
|
|
38
|
+
const endedKnown = Number.isFinite(previous.endedChildren) && Number.isFinite(sample.endedChildren);
|
|
39
|
+
if (sample.byPid.size === 0 && previous.byPid.size === 0 && !endedKnown) return null;
|
|
40
|
+
let seconds = 0, complete = true;
|
|
41
|
+
for (const [pid, now] of sample.byPid) {
|
|
42
|
+
const before = previous.byPid.get(pid);
|
|
43
|
+
if (before !== undefined) {
|
|
44
|
+
if (now >= before) seconds += now - before;
|
|
45
|
+
else complete = false;
|
|
46
|
+
} else if (sample.startedAt?.get(pid) > previous.takenAt) {
|
|
47
|
+
seconds += now;
|
|
48
|
+
} else {
|
|
49
|
+
complete = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let countedOfEnded = 0, ended = 0;
|
|
53
|
+
for (const [pid, before] of previous.byPid) {
|
|
54
|
+
if (sample.byPid.has(pid)) continue;
|
|
55
|
+
countedOfEnded += before;
|
|
56
|
+
ended += 1;
|
|
57
|
+
}
|
|
58
|
+
if (endedKnown) {
|
|
59
|
+
const rest = sample.endedChildren - previous.endedChildren - countedOfEnded;
|
|
60
|
+
if (rest >= 0) seconds += rest;
|
|
61
|
+
else complete = false;
|
|
62
|
+
} else if (ended > 0) {
|
|
63
|
+
complete = false;
|
|
64
|
+
}
|
|
65
|
+
return { seconds, complete };
|
|
66
|
+
}
|
|
@@ -136,10 +136,15 @@ export class ContainerOrchestrator {
|
|
|
136
136
|
let lifetime = this.#lifetimes.get(key);
|
|
137
137
|
if (!lifetime) this.#lifetimes.set(key, lifetime = {});
|
|
138
138
|
const isCurrent = () => this.#lifetimes.get(key) === lifetime && params.isCurrent?.() !== false;
|
|
139
|
-
const previous = this.#reads.get(key) ?? Promise.resolve();
|
|
140
139
|
// Reads of one file run one after another, so an answer can wait for every
|
|
141
140
|
// read asked before it. Told to whoever asked, apart from the read itself,
|
|
142
141
|
// because the two call for different remedies (torrent-tv/meta#166).
|
|
142
|
+
// A statement answered from what was already read reads and parses
|
|
143
|
+
// nothing, so it is answered beside the queue and holds nobody up: the
|
|
144
|
+
// picture's first input of an AVI waited about 4 s behind the sound's
|
|
145
|
+
// table of the whole film it does not use (r38, torrent-tv/meta#178).
|
|
146
|
+
const aside = this.#answersWithoutReading(key, statement);
|
|
147
|
+
const previous = aside ? Promise.resolve() : this.#reads.get(key) ?? Promise.resolve();
|
|
143
148
|
const askedAt = Date.now();
|
|
144
149
|
const reading = previous.catch(() => undefined).then(async () => {
|
|
145
150
|
if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
|
|
@@ -149,7 +154,7 @@ export class ContainerOrchestrator {
|
|
|
149
154
|
params.onTimed?.(statement, startedAt - askedAt, Date.now() - startedAt);
|
|
150
155
|
return { result, revision };
|
|
151
156
|
});
|
|
152
|
-
this.#reads.set(key, reading);
|
|
157
|
+
if (!aside) this.#reads.set(key, reading);
|
|
153
158
|
try {
|
|
154
159
|
const { result, revision } = await reading;
|
|
155
160
|
if (!isCurrent()) return { kind: "terminal", reason: "request-obsolete", requestId: params.requestId };
|
|
@@ -162,6 +167,12 @@ export class ContainerOrchestrator {
|
|
|
162
167
|
}
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
/** Whether a statement of this file is answered from what was already read: its track table kept here, or the container's word. */
|
|
171
|
+
#answersWithoutReading(key, statement) {
|
|
172
|
+
if (statement === "tracks" && this.tracks.has(key)) return true;
|
|
173
|
+
return this.cache.get(key)?.answersWithoutReading?.(statement) === true;
|
|
174
|
+
}
|
|
175
|
+
|
|
165
176
|
async #inspectRead(params, statement) {
|
|
166
177
|
const requestId = params.requestId ?? `${params.sourceKey}:${params.fileIndex}:${statement}`;
|
|
167
178
|
this.#activityEpoch++;
|
|
@@ -42,6 +42,8 @@ export class AviContainer extends Container {
|
|
|
42
42
|
#workTags = null;
|
|
43
43
|
/** The index alone: undefined until read, null when the file states none. */
|
|
44
44
|
#index = undefined;
|
|
45
|
+
/** The index while it is being read, so two asks read it once. */
|
|
46
|
+
#indexReading = null;
|
|
45
47
|
#layout = null;
|
|
46
48
|
/** The first RIFF's `idx1`: undefined until looked for, null when there is none. */
|
|
47
49
|
#idx1Chunk = undefined;
|
|
@@ -167,6 +169,38 @@ export class AviContainer extends Container {
|
|
|
167
169
|
*/
|
|
168
170
|
async #readIndex() {
|
|
169
171
|
if (this.#index !== undefined) return this.#index;
|
|
172
|
+
// One reading for every asker: a read of the picture's ranges may now be
|
|
173
|
+
// answered beside the sound's table (`answersWithoutReading`), and two
|
|
174
|
+
// asks at once would each read the index and each hold its memory.
|
|
175
|
+
this.#indexReading ??= this.#readIndexOnce().finally(() => { this.#indexReading = null; });
|
|
176
|
+
return this.#indexReading;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether `statement` is answered from what this container has already
|
|
181
|
+
* read, without reading or parsing anything more — so it may be answered
|
|
182
|
+
* beside a read of the same file still in progress
|
|
183
|
+
* (`ContainerOrchestrator.inspect`). Field r38 (Home Assistant, 2026-10-14):
|
|
184
|
+
* the picture's first input waited about 4 s behind the sound's packet table
|
|
185
|
+
* of the whole film, which it does not use — its ranges are named from the
|
|
186
|
+
* index alone (`readSourceRanges`), while the table is the sound's
|
|
187
|
+
* (`readPacketIndex`), built in portions that give the thread back
|
|
188
|
+
* (torrent-tv/meta#178).
|
|
189
|
+
*
|
|
190
|
+
* Once the index has been read, so have the tracks and the place of `movi`
|
|
191
|
+
* it was read with, and the picture's statements read nothing else.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} statement
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
answersWithoutReading(statement) {
|
|
197
|
+
if (statement === "media-info") return Boolean(this.mediaInfo);
|
|
198
|
+
if (statement === "source-navigation" || statement === "source-ranges") return this.#index !== undefined;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The index, read from the file; see `#readIndex`. */
|
|
203
|
+
async #readIndexOnce() {
|
|
170
204
|
const tracks = await this.readTracks();
|
|
171
205
|
const { streams } = await this.#readHeaders();
|
|
172
206
|
const { movi } = await this.#topLevel();
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
import { KeyframeTables } from "../media/KeyframeTables.js";
|
|
13
13
|
import { waits } from "./WaitLedger.js";
|
|
14
|
-
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
|
|
14
|
+
import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
|
|
15
15
|
import { minimumBufferFrom } from "../torrent/supply-margin.js";
|
|
16
16
|
import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
|
|
17
17
|
import { urgentOutputsReady } from "../viewer/urgent-output-ready.js";
|
|
@@ -290,6 +290,7 @@ export function wireOutputs({
|
|
|
290
290
|
// What the torrent and the proxy itself spend on this host, and how fast each watched torrent moves.
|
|
291
291
|
parts.hostLoad = new HostLoad({
|
|
292
292
|
readMetadataActivity,
|
|
293
|
+
readEndedChildrenCpuSeconds,
|
|
293
294
|
readMachineState,
|
|
294
295
|
readProcessCpuSeconds,
|
|
295
296
|
readProxyCpuSeconds,
|
|
@@ -46,6 +46,8 @@ export class SwarmSelection {
|
|
|
46
46
|
#stated = new Map();
|
|
47
47
|
/** Claimants whose windows are currently protected in memory. */
|
|
48
48
|
#protectedInMemory = new Set();
|
|
49
|
+
/** The pieces of the urgent first class, as last stated: what somebody waits for now. */
|
|
50
|
+
#required = [];
|
|
49
51
|
#findStore;
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -110,7 +112,10 @@ export class SwarmSelection {
|
|
|
110
112
|
else union.push({ from: range.from, to: range.to, priority });
|
|
111
113
|
}
|
|
112
114
|
for (const range of union) wanted.set(`${range.from}-${range.to}-${priority}`, range);
|
|
113
|
-
if (priority === 1)
|
|
115
|
+
if (priority === 1) {
|
|
116
|
+
this.firstClassPieces = union.map(range => range.from === range.to ? `${range.from}` : `${range.from}-${range.to}`);
|
|
117
|
+
this.#required = strictFirstClass ? union.map(({ from, to }) => ({ from, to })) : [];
|
|
118
|
+
}
|
|
114
119
|
}
|
|
115
120
|
this.firstClass = first;
|
|
116
121
|
|
|
@@ -150,8 +155,21 @@ export class SwarmSelection {
|
|
|
150
155
|
return { stated, withdrawn };
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The pieces of the urgent first class as last stated, or none while the
|
|
160
|
+
* first class is conditional: what somebody is waiting for now, and the
|
|
161
|
+
* only pieces whose last blocks are worth asking of a second peer
|
|
162
|
+
* (`TailRepeat`).
|
|
163
|
+
*
|
|
164
|
+
* @returns {Array<{ from: number, to: number }>}
|
|
165
|
+
*/
|
|
166
|
+
requiredPieces() {
|
|
167
|
+
return this.#required;
|
|
168
|
+
}
|
|
169
|
+
|
|
153
170
|
/** Take everything back. The torrent is going, or nobody wants anything. */
|
|
154
171
|
releaseAll() {
|
|
172
|
+
this.#required = [];
|
|
155
173
|
for (const range of this.#stated.values()) {
|
|
156
174
|
this.#deselect(range);
|
|
157
175
|
}
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
* @property {string | null} slowestPeer - Who sent that block.
|
|
21
21
|
* @property {number} lastInFlightMs - How long the block that completed the piece was in flight.
|
|
22
22
|
* @property {string | null} lastPeer - Who sent it.
|
|
23
|
+
* @property {number} askedAgain - Asks of a block already asked of another peer.
|
|
24
|
+
* @property {number} arrivedTwice - Blocks that arrived after a copy of them had, and were dropped.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
export class PieceTimings {
|
|
26
|
-
/** @type {Map<number, { firstAskAt: number, asks: number, peers: Set<string>, blocks: Map<number, number>, slowestMs: number, slowestPeer: string | null, lastInFlightMs: number, lastPeer: string | null }>} */
|
|
28
|
+
/** @type {Map<number, { firstAskAt: number, asks: number, peers: Set<string>, blocks: Map<number, number>, arrived: Set<number>, askedAgain: number, arrivedTwice: number, slowestMs: number, slowestPeer: string | null, lastInFlightMs: number, lastPeer: string | null }>} */
|
|
27
29
|
#pieces = new Map();
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -37,12 +39,14 @@ export class PieceTimings {
|
|
|
37
39
|
asked(piece, offset, peer, now) {
|
|
38
40
|
let record = this.#pieces.get(piece);
|
|
39
41
|
if (!record) {
|
|
40
|
-
record = { firstAskAt: now, asks: 0, peers: new Set(), blocks: new Map(),
|
|
42
|
+
record = { firstAskAt: now, asks: 0, peers: new Set(), blocks: new Map(), arrived: new Set(), askedAgain: 0, arrivedTwice: 0,
|
|
43
|
+
slowestMs: 0, slowestPeer: null, lastInFlightMs: 0, lastPeer: null };
|
|
41
44
|
this.#pieces.set(piece, record);
|
|
42
45
|
}
|
|
43
46
|
record.asks += 1;
|
|
44
47
|
record.peers.add(peer);
|
|
45
48
|
if (!record.blocks.has(offset)) record.blocks.set(offset, now);
|
|
49
|
+
else record.askedAgain += 1;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -58,6 +62,13 @@ export class PieceTimings {
|
|
|
58
62
|
const record = this.#pieces.get(piece);
|
|
59
63
|
const askedAt = record?.blocks.get(offset);
|
|
60
64
|
if (askedAt === undefined) return;
|
|
65
|
+
// A copy of a block that had already arrived is dropped by the piece; its
|
|
66
|
+
// bytes are what asking a second peer cost.
|
|
67
|
+
if (record.arrived.has(offset)) {
|
|
68
|
+
record.arrivedTwice += 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
record.arrived.add(offset);
|
|
61
72
|
const inFlightMs = now - askedAt;
|
|
62
73
|
if (inFlightMs >= record.slowestMs) {
|
|
63
74
|
record.slowestMs = inFlightMs;
|
|
@@ -86,7 +97,9 @@ export class PieceTimings {
|
|
|
86
97
|
slowestMs: record.slowestMs,
|
|
87
98
|
slowestPeer: record.slowestPeer,
|
|
88
99
|
lastInFlightMs: record.lastInFlightMs,
|
|
89
|
-
lastPeer: record.lastPeer
|
|
100
|
+
lastPeer: record.lastPeer,
|
|
101
|
+
askedAgain: record.askedAgain,
|
|
102
|
+
arrivedTwice: record.arrivedTwice
|
|
90
103
|
};
|
|
91
104
|
}
|
|
92
105
|
}
|
|
@@ -101,5 +114,7 @@ export function describePieceTiming(timing) {
|
|
|
101
114
|
if (!timing) return "";
|
|
102
115
|
return `; first block asked ${(timing.askedAgoMs / 1000).toFixed(1)}s before, ${timing.asks} block ask(s) to ${timing.peers} peer(s), ` +
|
|
103
116
|
`slowest block ${(timing.slowestMs / 1000).toFixed(1)}s in flight from ${timing.slowestPeer ?? "?"}, ` +
|
|
104
|
-
`the last one ${(timing.lastInFlightMs / 1000).toFixed(1)}s from ${timing.lastPeer ?? "?"}
|
|
117
|
+
`the last one ${(timing.lastInFlightMs / 1000).toFixed(1)}s from ${timing.lastPeer ?? "?"}` +
|
|
118
|
+
(timing.askedAgain > 0 || timing.arrivedTwice > 0
|
|
119
|
+
? `, ${timing.askedAgain} block(s) asked again, ${timing.arrivedTwice} arrived twice` : "");
|
|
105
120
|
}
|
|
@@ -25,6 +25,22 @@ import { deadlineOfMapZone, levelOfMapZone, priorityOfMapZone } from "./map-zone
|
|
|
25
25
|
import { deriveSourceKey } from "../../utils/torrent-source-key.js";
|
|
26
26
|
import { noteTorrentDestroyed } from "./destroyed-torrents.js";
|
|
27
27
|
import { PieceTimings, describePieceTiming } from "./piece-timing.js";
|
|
28
|
+
import { TailRepeat } from "./worker/tail-repeat.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The second-peer requests for the last blocks of a piece somebody waits for,
|
|
32
|
+
* one per torrent, made when the torrent is first watched and stopped when it
|
|
33
|
+
* is forgotten (`worker/tail-repeat.js`).
|
|
34
|
+
*
|
|
35
|
+
* @type {WeakMap<object, TailRepeat>}
|
|
36
|
+
*/
|
|
37
|
+
const tailRepeats = new WeakMap();
|
|
38
|
+
|
|
39
|
+
/** Stop asking second peers for a torrent that is going. */
|
|
40
|
+
function dropTailRepeat(torrent) {
|
|
41
|
+
tailRepeats.get(torrent)?.dispose();
|
|
42
|
+
tailRepeats.delete(torrent);
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
/** How a window stated from the priority map names itself. */
|
|
30
46
|
const MAP_CLAIMANT = "priority-map";
|
|
@@ -1353,6 +1369,8 @@ export class TorrentPool {
|
|
|
1353
1369
|
// swarm went on fetching the old first class for 1.16 s, until the next
|
|
1354
1370
|
// piece happened to be verified (r34, torrent-tv/meta#178).
|
|
1355
1371
|
reconcileAll();
|
|
1372
|
+
// And a new map may make other pieces the ones somebody waits for.
|
|
1373
|
+
tailRepeats.get(torrent)?.poke();
|
|
1356
1374
|
// WHAT THE SWARM WAS ACTUALLY TOLD, said on change and never on a timer.
|
|
1357
1375
|
// The map was applied in silence: that it had been BUILT was visible in the
|
|
1358
1376
|
// encoding's own line, and that the download had received it was visible
|
|
@@ -1515,6 +1533,8 @@ export class TorrentPool {
|
|
|
1515
1533
|
// its blocks were in flight, and from whom (`piece-timing.js`). One line
|
|
1516
1534
|
// per piece: a 2.3 GB film is 563.
|
|
1517
1535
|
const timings = new PieceTimings();
|
|
1536
|
+
tailRepeats.set(torrent, new TailRepeat({ torrent, requiredPieces: () => demandFor(torrent).selection.requiredPieces(),
|
|
1537
|
+
log: (line) => logger.info(`download ${label()} ${line}`) }));
|
|
1518
1538
|
torrent.on("wire", (wire) => {
|
|
1519
1539
|
// WebTorrent asks a wire for a block by calling `request` and states
|
|
1520
1540
|
// nothing; a request the wire refuses (choked, closed) is not queued and
|
|
@@ -1963,6 +1983,7 @@ export class TorrentPool {
|
|
|
1963
1983
|
return;
|
|
1964
1984
|
}
|
|
1965
1985
|
forgetTorrent(torrent);
|
|
1986
|
+
dropTailRepeat(torrent);
|
|
1966
1987
|
this.#forgetBookkeeping(torrent);
|
|
1967
1988
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
1968
1989
|
logger.warn(
|
|
@@ -2008,6 +2029,7 @@ export class TorrentPool {
|
|
|
2008
2029
|
#removeTorrent(torrent, reason = "unknown") {
|
|
2009
2030
|
// Everything anybody stated for this torrent goes with it.
|
|
2010
2031
|
forgetTorrent(torrent);
|
|
2032
|
+
dropTailRepeat(torrent);
|
|
2011
2033
|
if (!torrent) {
|
|
2012
2034
|
return;
|
|
2013
2035
|
}
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
*/
|
|
38
38
|
const SHORT_TAIL_BLOCKS = 16;
|
|
39
39
|
|
|
40
|
+
/** A block of a piece, as `torrent-piece` cuts it (`Piece.BLOCK_LENGTH`): a request's offset over this is its block. */
|
|
41
|
+
const BLOCK_BYTES = 1 << 14;
|
|
42
|
+
|
|
40
43
|
/**
|
|
41
44
|
* The library's own request entry. Internal, so its absence must be noticed
|
|
42
45
|
* rather than swallowed: without it this lever silently does nothing.
|
|
@@ -248,10 +251,10 @@ export function describePieceTail(torrent, pieceIndex) {
|
|
|
248
251
|
* work, which no average speed can show.
|
|
249
252
|
*
|
|
250
253
|
* The cost is bounded by the same measurement: the tails were 2 to 14 blocks of
|
|
251
|
-
* 512, so 32 to 224 KB against the 8 MiB piece they hold up.
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
254
|
+
* 512, so 32 to 224 KB against the 8 MiB piece they hold up. A block already in
|
|
255
|
+
* flight on `maxCopies` wires is not asked again, however often this is called:
|
|
256
|
+
* it is called on every block that arrives (`TailRepeat`), and without that
|
|
257
|
+
* check each call asked one more copy of the same block.
|
|
255
258
|
*
|
|
256
259
|
* Safe against the completion path: a block that arrives twice is dropped by
|
|
257
260
|
* `Piece.set`, which writes only into an empty slot, and a piece already
|
|
@@ -260,17 +263,26 @@ export function describePieceTail(torrent, pieceIndex) {
|
|
|
260
263
|
*
|
|
261
264
|
* @param {object} torrent
|
|
262
265
|
* @param {number} pieceIndex
|
|
263
|
-
* @
|
|
266
|
+
* @param {object} [options]
|
|
267
|
+
* @param {number} [options.maxCopies] - How many wires a missing block may be
|
|
268
|
+
* in flight on at once: the first request and one copy beside it.
|
|
269
|
+
* @returns {{ duplicated: number, missing: number, wires: number, unplaced: number }}
|
|
264
270
|
* `duplicated` counts requests the library actually placed for a block that
|
|
265
|
-
* was already outstanding elsewhere
|
|
271
|
+
* was already outstanding elsewhere; `unplaced` the blocks in flight on one
|
|
272
|
+
* wire alone that no other wire could be asked for now.
|
|
266
273
|
*/
|
|
267
|
-
export function duplicateTailFor(torrent, pieceIndex) {
|
|
274
|
+
export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
|
|
268
275
|
const piece = torrent?.pieces?.[pieceIndex];
|
|
269
276
|
const buffer = Array.isArray(piece?._buffer) ? piece._buffer : null;
|
|
270
|
-
if (!piece || !buffer || typeof piece.cancel !== "function") {
|
|
271
|
-
return { duplicated: 0, missing: 0, wires: 0 };
|
|
277
|
+
if (!piece || !buffer || typeof piece.cancel !== "function" || !canPlaceRequests(torrent)) {
|
|
278
|
+
return { duplicated: 0, missing: 0, wires: 0, unplaced: 0 };
|
|
272
279
|
}
|
|
273
280
|
const chunks = Number.isFinite(piece._chunks) ? piece._chunks : buffer.length;
|
|
281
|
+
// Most calls are for a piece still arriving in bulk; its own count says so
|
|
282
|
+
// without walking its blocks or every wire's queue.
|
|
283
|
+
if (Number.isFinite(piece._buffered) && chunks - piece._buffered > SHORT_TAIL_BLOCKS) {
|
|
284
|
+
return { duplicated: 0, missing: chunks - piece._buffered, wires: 0, unplaced: 0 };
|
|
285
|
+
}
|
|
274
286
|
const missing = [];
|
|
275
287
|
for (let index = 0; index < chunks; index += 1) {
|
|
276
288
|
if (!buffer[index]) {
|
|
@@ -279,7 +291,7 @@ export function duplicateTailFor(torrent, pieceIndex) {
|
|
|
279
291
|
}
|
|
280
292
|
const candidates = wiresForPiece(torrent, pieceIndex);
|
|
281
293
|
if (missing.length === 0 || candidates.length === 0) {
|
|
282
|
-
return { duplicated: 0, missing: missing.length, wires: candidates.length };
|
|
294
|
+
return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
|
|
283
295
|
}
|
|
284
296
|
|
|
285
297
|
// A tail this short is what a blocked read is actually waiting on, and a
|
|
@@ -288,31 +300,50 @@ export function duplicateTailFor(torrent, pieceIndex) {
|
|
|
288
300
|
// bytes that are already coming — measured 2026-08-19, the tails a reader
|
|
289
301
|
// waits on are 2 to 14 blocks of 512.
|
|
290
302
|
if (missing.length > SHORT_TAIL_BLOCKS) {
|
|
291
|
-
return { duplicated: 0, missing: missing.length, wires: candidates.length };
|
|
303
|
+
return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Which wires each missing block is in flight on, read from the wires' own
|
|
307
|
+
// queues: the library keeps a block's reservation for one wire only, and a
|
|
308
|
+
// copy asked here is visible nowhere else.
|
|
309
|
+
const holders = new Map(missing.map(block => [block, new Set()]));
|
|
310
|
+
for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) {
|
|
311
|
+
for (const request of Array.isArray(wire?.requests) ? wire.requests : []) {
|
|
312
|
+
if (request?.piece !== pieceIndex) continue;
|
|
313
|
+
holders.get(Math.floor(request.offset / BLOCK_BYTES))?.add(wire);
|
|
314
|
+
}
|
|
292
315
|
}
|
|
293
316
|
|
|
294
|
-
let duplicated = 0;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
317
|
+
let duplicated = 0, unplaced = 0;
|
|
318
|
+
for (const block of missing) {
|
|
319
|
+
const held = holders.get(block);
|
|
320
|
+
// Not in flight anywhere: the library's own picker asks for it; a block
|
|
321
|
+
// nobody has been asked for is not a tail held by a slow peer.
|
|
322
|
+
if (held.size === 0 || held.size >= maxCopies) continue;
|
|
323
|
+
let placed = false;
|
|
324
|
+
for (const wire of candidates) {
|
|
325
|
+
if (held.has(wire)) continue;
|
|
326
|
+
// Freeing the reservation is what makes the block askable again; the
|
|
327
|
+
// request already in flight is deliberately left alone, because the
|
|
328
|
+
// point is to have two of them.
|
|
329
|
+
piece.cancel(block);
|
|
330
|
+
// `false` is hotswap: the library's own swap must not run on top of
|
|
331
|
+
// this, or it would free a THIRD wire's block as well.
|
|
332
|
+
if (torrent._request(wire, pieceIndex, false) === true) {
|
|
333
|
+
held.add(wire);
|
|
334
|
+
duplicated += 1;
|
|
335
|
+
placed = true;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
// Refused before anything was reserved (that wire's pipeline is full):
|
|
339
|
+
// the freed block is taken back, or the library would hand it to the
|
|
340
|
+
// next wire that asks, a copy nobody counted. The next wire's pipeline
|
|
341
|
+
// is its own, so it is still asked.
|
|
342
|
+
piece.reserve();
|
|
306
343
|
}
|
|
307
|
-
|
|
308
|
-
// next one's — pipelines are per wire. This used to stop the whole pass on
|
|
309
|
-
// the first refusal, on the stated reasoning that the remaining wires were
|
|
310
|
-
// "no emptier", which is an assumption about other peers' queues that
|
|
311
|
-
// nothing here measures. The block whose reservation was freed stays in the
|
|
312
|
-
// piece's cancellation stack and is handed to whoever asks next, which is
|
|
313
|
-
// harmless: it is already in flight elsewhere.
|
|
344
|
+
if (!placed) unplaced += 1;
|
|
314
345
|
}
|
|
315
|
-
return { duplicated, missing: missing.length, wires: candidates.length };
|
|
346
|
+
return { duplicated, missing: missing.length, wires: candidates.length, unplaced };
|
|
316
347
|
}
|
|
317
348
|
|
|
318
349
|
/**
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Ask a second peer for the last blocks of a piece somebody is waiting
|
|
3
|
+
* for, when one slow peer holds them (torrent-tv/meta#178).
|
|
4
|
+
*
|
|
5
|
+
* Field r40 (Home Assistant, 2026-10-14, a cold 1080p MKV): the first run's
|
|
6
|
+
* input waited 10.0 s, last for piece 8, whose slowest block was 4.5 s in
|
|
7
|
+
* flight from one peer; pieces 14-16 waited 10.0-11.5 s on one peer each. A
|
|
8
|
+
* block is reserved for one wire, and the piece completes when its slowest
|
|
9
|
+
* holder delivers (`duplicateTailFor`). Nothing had asked a second peer since
|
|
10
|
+
* the blocked-read path that did so was removed (`0e38ec3`, torrent-tv/meta#95).
|
|
11
|
+
*
|
|
12
|
+
* Which pieces: those of the urgent first class the swarm was last told
|
|
13
|
+
* (`SwarmSelection.requiredPieces`) — what somebody waits for now. A map that
|
|
14
|
+
* withdraws them, or an input that is ready, takes them out, and nothing more
|
|
15
|
+
* is asked for them.
|
|
16
|
+
*
|
|
17
|
+
* When: a tail changes when a block arrives, a wire's queue moves on, or a
|
|
18
|
+
* peer becomes able or unable to give the piece; so it is looked at on those —
|
|
19
|
+
* a block arriving, a wire unchoking, a peer stating a piece, a wire joining
|
|
20
|
+
* or closing, a piece verified, a new map. Several in one turn of the event
|
|
21
|
+
* loop are one look. A tail no other wire could be asked for now is looked at
|
|
22
|
+
* once more after the time a wire's queue is sized to empty in — WebTorrent
|
|
23
|
+
* sizes it to one second of the wire's speed (`PIPELINE_MAX_DURATION`) — in
|
|
24
|
+
* case nothing else happens in between.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { duplicateTailFor } from "./fastest-wires.js";
|
|
28
|
+
|
|
29
|
+
/** WebTorrent's `PIPELINE_MAX_DURATION`: a wire is given one second of its speed to fetch. */
|
|
30
|
+
const WIRE_QUEUE_SECONDS = 1;
|
|
31
|
+
|
|
32
|
+
/** What a wire says, besides a block arriving, that can change who could be asked for a piece. */
|
|
33
|
+
const WIRE_EVENTS = ["unchoke", "have", "bitfield"];
|
|
34
|
+
|
|
35
|
+
export class TailRepeat {
|
|
36
|
+
#torrent;
|
|
37
|
+
#requiredPieces;
|
|
38
|
+
#duplicate;
|
|
39
|
+
#schedule;
|
|
40
|
+
#wake;
|
|
41
|
+
#clear;
|
|
42
|
+
#scheduled = false;
|
|
43
|
+
#timer = null;
|
|
44
|
+
#disposed = false;
|
|
45
|
+
/** @type {Map<object, { onBlock: (index: number) => void, onPeer: () => void }>} */
|
|
46
|
+
#wires = new Map();
|
|
47
|
+
#onWire;
|
|
48
|
+
#onPoke;
|
|
49
|
+
#log;
|
|
50
|
+
/**
|
|
51
|
+
* Grow-only, for the log: block copies asked, looks taken, and blocks that
|
|
52
|
+
* arrived after their piece was complete — what the copies cost.
|
|
53
|
+
*/
|
|
54
|
+
totals = { duplicated: 0, looks: 0, lateBlocks: 0 };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} params
|
|
58
|
+
* @param {object} params.torrent
|
|
59
|
+
* @param {() => Array<{ from: number, to: number }>} params.requiredPieces
|
|
60
|
+
* @param {typeof duplicateTailFor} [params.duplicate]
|
|
61
|
+
* @param {(work: () => void) => void} [params.schedule] - Runs work once this turn of the event loop is over.
|
|
62
|
+
* @param {(work: () => void, ms: number) => unknown} [params.wake]
|
|
63
|
+
* @param {(handle: unknown) => void} [params.clear]
|
|
64
|
+
* @param {(line: string) => void} [params.log]
|
|
65
|
+
*/
|
|
66
|
+
constructor({ torrent, requiredPieces, duplicate = duplicateTailFor, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null }) {
|
|
67
|
+
this.#log = log;
|
|
68
|
+
this.#torrent = torrent;
|
|
69
|
+
this.#requiredPieces = requiredPieces;
|
|
70
|
+
this.#duplicate = duplicate;
|
|
71
|
+
this.#schedule = schedule;
|
|
72
|
+
this.#wake = wake;
|
|
73
|
+
this.#clear = clear;
|
|
74
|
+
this.#onWire = (wire) => this.#watch(wire);
|
|
75
|
+
this.#onPoke = () => this.poke();
|
|
76
|
+
torrent.on?.("wire", this.#onWire);
|
|
77
|
+
torrent.on?.("verified", this.#onPoke);
|
|
78
|
+
for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) this.#watch(wire);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Look at the tails once this turn of the event loop is over. */
|
|
82
|
+
poke() {
|
|
83
|
+
if (this.#scheduled || this.#disposed) return;
|
|
84
|
+
this.#scheduled = true;
|
|
85
|
+
this.#schedule(() => this.#look());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stop watching: the torrent is going. */
|
|
89
|
+
dispose() {
|
|
90
|
+
this.#disposed = true;
|
|
91
|
+
this.#torrent.removeListener?.("wire", this.#onWire);
|
|
92
|
+
this.#torrent.removeListener?.("verified", this.#onPoke);
|
|
93
|
+
for (const wire of [...this.#wires.keys()]) this.#forget(wire);
|
|
94
|
+
if (this.#timer !== null) this.#clear(this.#timer);
|
|
95
|
+
this.#timer = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#watch(wire) {
|
|
99
|
+
if (!wire || this.#wires.has(wire) || typeof wire.on !== "function") return;
|
|
100
|
+
const listeners = {
|
|
101
|
+
onBlock: (index) => {
|
|
102
|
+
// A block of a piece already verified is a copy that came second; the
|
|
103
|
+
// block that completes a piece arrives before the piece is verified.
|
|
104
|
+
if (Number.isInteger(index) && this.#torrent.bitfield?.get?.(index)) this.totals.lateBlocks += 1;
|
|
105
|
+
this.poke();
|
|
106
|
+
},
|
|
107
|
+
onPeer: () => this.poke()
|
|
108
|
+
};
|
|
109
|
+
this.#wires.set(wire, listeners);
|
|
110
|
+
wire.on("piece", listeners.onBlock);
|
|
111
|
+
for (const event of WIRE_EVENTS) wire.on(event, listeners.onPeer);
|
|
112
|
+
wire.once?.("close", () => {
|
|
113
|
+
this.#forget(wire);
|
|
114
|
+
this.poke();
|
|
115
|
+
});
|
|
116
|
+
this.poke();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#forget(wire) {
|
|
120
|
+
const listeners = this.#wires.get(wire);
|
|
121
|
+
if (!listeners) return;
|
|
122
|
+
wire.removeListener?.("piece", listeners.onBlock);
|
|
123
|
+
for (const event of WIRE_EVENTS) wire.removeListener?.(event, listeners.onPeer);
|
|
124
|
+
this.#wires.delete(wire);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#look() {
|
|
128
|
+
this.#scheduled = false;
|
|
129
|
+
if (this.#disposed) return;
|
|
130
|
+
this.totals.looks += 1;
|
|
131
|
+
let unplaced = 0;
|
|
132
|
+
const asked = [];
|
|
133
|
+
for (const { from, to } of this.#requiredPieces()) {
|
|
134
|
+
for (let index = from; index <= to; index += 1) {
|
|
135
|
+
if (this.#torrent.bitfield?.get?.(index)) continue;
|
|
136
|
+
const result = this.#duplicate(this.#torrent, index);
|
|
137
|
+
this.totals.duplicated += result.duplicated;
|
|
138
|
+
unplaced += result.unplaced;
|
|
139
|
+
if (result.duplicated > 0) asked.push(`${index}:${result.duplicated}/${result.missing}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (asked.length > 0) {
|
|
143
|
+
this.#log?.(`tail: asked a second peer for the last blocks of piece:blocks/missing ${asked.join(" ")}; ` +
|
|
144
|
+
`${this.totals.duplicated} block copies asked so far, ${this.totals.lateBlocks} block(s) arrived after their piece was complete`);
|
|
145
|
+
}
|
|
146
|
+
if (unplaced > 0 && this.#timer === null) {
|
|
147
|
+
this.#timer = this.#wake(() => {
|
|
148
|
+
this.#timer = null;
|
|
149
|
+
this.poke();
|
|
150
|
+
}, WIRE_QUEUE_SECONDS * 1000);
|
|
151
|
+
this.#timer?.unref?.();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|