@torrent-tv/proxy 2.9.97 → 2.9.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +83 -0
- package/routes/stream/get.js +7 -1
- package/server.js +4 -0
- package/services/hls-session-manager.js +34 -1
- package/services/torrent-pool.js +65 -9
- package/test/read-window.test.js +2 -1
- package/test/upload-hurry.test.js +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.9.99
|
|
2
|
+
|
|
3
|
+
- **New**: A source can be told to start before anyone asks to play it — `POST /api/sources/:sourceKey/warm`. Everything a cold torrent must do first takes seconds and none of it depends on which file is wanted: announce to the trackers, connect to peers, be unchoked by them. Given a file index it also fetches the two pieces at that file's edges, which is what the codec probe reads and what took **6.7 s of the 10.3 s** before playback in the session measured 2026-08-04. All of it used to begin only once a file had been chosen, because it was buried inside the playback plan. The route returns as soon as the work is under way and reports a refusal rather than an error — nothing is broken if a warm-up does not happen, since the ordinary path still does all of it.
|
|
4
|
+
- **Chore**: Two callers asking for the same file's edges at once now share one prefetch instead of opening a second pair of readers, each claiming a window and holding pieces. That happens by design on a single-video torrent, where the warm-up and the playback plan both want them.
|
|
5
|
+
|
|
6
|
+
## 2.9.98
|
|
7
|
+
|
|
8
|
+
- **Fix**: The upload is no longer raised at moments when nobody wants a byte. A torrent with no reader was counted as starving whenever its download read low — which it always does while the encoder is held back for running ahead of the viewer. Measured: four cycles of 512 KB/s and back in three minutes, each reported as `earn unchoke … down=0KB/s`. Starvation now requires somebody to be waiting.
|
|
9
|
+
- **Fix**: A session start no longer looks like a burst of seeks. The codec probe and the keyframe index read through the same route as the encoder and visit the first bytes and the last ones, which from byte offsets alone is indistinguishable from a viewer dragging the slider — two spurious "the viewer moved" per start, and more on every encoder restart. The encoder's input URL now says that it is the read that follows the viewer, and only that read counts.
|
|
10
|
+
- **Chore**: The per-run ffmpeg log line abbreviates the list of cut times to its count and its two ends. There is one cut per segment — 830 on a two-hour film, about 7 KB of log per run — and the list is only ever consulted for whether cutting was explicit, where it starts and how far it reaches.
|
|
11
|
+
|
|
1
12
|
## 2.9.97
|
|
2
13
|
|
|
3
14
|
- **Fix**: The generous upload of 2.9.96 did not actually reach the moment it was written for. Only torrents with a registered reader were shown to the upload policy, and the first thing done with a new torrent — fetching the file's head and tail for the codec probe — reads through `createReadStream` without registering one. So for the whole of that wait, 8.36 s of the 11.46 s before playback in the measured session, the torrent looked unused and the upload stayed at the near-silent idle floor, during the exact seconds peers decide whether to serve us. A torrent in a hurry now counts whether or not anything is reading it. The selection is a named function of its own so it can be tested without a live swarm — the fault was in which torrents were considered, not in what was decided about them.
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Start fetching a source before anyone asks to play it.
|
|
5
|
+
*
|
|
6
|
+
* POST /api/sources/:sourceKey/warm { fileIndex?: number }
|
|
7
|
+
*
|
|
8
|
+
* Everything a torrent must do before the first byte of video can be served
|
|
9
|
+
* takes seconds and none of it depends on the viewer: announce to the
|
|
10
|
+
* trackers, connect to peers, be unchoked by them, and fetch the two pieces at
|
|
11
|
+
* the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
|
|
12
|
+
* 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
|
|
13
|
+
* arriving, with the swarm ramping from nothing.
|
|
14
|
+
*
|
|
15
|
+
* That work used to begin only when a file had been chosen, because it was
|
|
16
|
+
* buried inside the playback plan. It can begin as soon as the viewer has
|
|
17
|
+
* picked a TORRENT — while they are still reading the list of episodes — and
|
|
18
|
+
* then most or all of it has happened by the time they choose.
|
|
19
|
+
*
|
|
20
|
+
* Returns as soon as the work is under way. The caller is not waiting for a
|
|
21
|
+
* result; it is only saying "you may start". Failures are logged and answered
|
|
22
|
+
* as `started: false` rather than as an error, because nothing is broken if a
|
|
23
|
+
* warm-up does not happen — the ordinary path still does all of it.
|
|
24
|
+
*
|
|
25
|
+
* @param {import("fastify").FastifyRequest} req
|
|
26
|
+
* @param {import("fastify").FastifyReply} reply
|
|
27
|
+
* @param {{
|
|
28
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
29
|
+
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
30
|
+
* }} deps
|
|
31
|
+
* @returns {Promise<void>}
|
|
32
|
+
*/
|
|
33
|
+
export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool }) {
|
|
34
|
+
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
35
|
+
if (!sourceKey) {
|
|
36
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
40
|
+
if (!sourceRecord) {
|
|
41
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
45
|
+
const requestedIndex = Number(body.fileIndex);
|
|
46
|
+
const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
|
|
47
|
+
|
|
48
|
+
// Adding the torrent is what announces to the trackers and starts connecting
|
|
49
|
+
// to peers, and it is also what a magnet needs in order to fetch its
|
|
50
|
+
// metadata. It is awaited because everything else needs the torrent object,
|
|
51
|
+
// and because until it resolves there is nothing to report.
|
|
52
|
+
let torrent;
|
|
53
|
+
try {
|
|
54
|
+
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
|
|
58
|
+
return reply.send({ started: false, swarm: false, edges: false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The edges are only worth fetching once it is known WHICH file will be
|
|
62
|
+
// played: on a season pack, warming twenty episodes' worth would spend the
|
|
63
|
+
// pool owner's bandwidth on nineteen files nobody opened. The caller passes
|
|
64
|
+
// an index when the torrent holds a single video, and again later if it
|
|
65
|
+
// wants to.
|
|
66
|
+
let edges = false;
|
|
67
|
+
if (fileIndex !== null && torrent.files?.[fileIndex]) {
|
|
68
|
+
edges = true;
|
|
69
|
+
// Deliberately not awaited: this is the multi-second part, and the point of
|
|
70
|
+
// the whole route is that the viewer goes on choosing while it happens.
|
|
71
|
+
Promise.resolve(torrentPool.prefetchFileEdges(torrent, fileIndex)).catch((error) => {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
logger.warn(`warm ${sourceKey.slice(0, 8)}: file edges failed: ${message}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger.info(
|
|
78
|
+
`warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
|
|
79
|
+
(edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet")
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return reply.send({ started: true, swarm: true, edges });
|
|
83
|
+
}
|
package/routes/stream/get.js
CHANGED
|
@@ -144,8 +144,14 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
|
|
|
144
144
|
// byte offset) downloads first instead of waiting behind the sequential
|
|
145
145
|
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
146
146
|
// undownloaded region.
|
|
147
|
+
// Only the encoder's own input read tracks where the viewer is. Everything
|
|
148
|
+
// else that comes through here — the codec probe, the keyframe index, a
|
|
149
|
+
// subtitle fetch — reads the file's edges, and treating those as a viewer
|
|
150
|
+
// position made every session start and every encoder restart look like a
|
|
151
|
+
// burst of seeks (measured: two spurious "the viewer moved" per start).
|
|
147
152
|
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
|
|
148
|
-
wholeFileRead: range === null
|
|
153
|
+
wholeFileRead: range === null,
|
|
154
|
+
isPlaybackRead: req.query.reader === "playback"
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
const start = range ? range.start : 0;
|
package/server.js
CHANGED
|
@@ -19,6 +19,7 @@ import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
|
19
19
|
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
20
20
|
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
21
21
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
22
|
+
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
22
23
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
23
24
|
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
24
25
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
@@ -176,6 +177,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
176
177
|
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
177
178
|
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
|
|
178
179
|
);
|
|
180
|
+
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
181
|
+
handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
|
|
182
|
+
);
|
|
179
183
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
180
184
|
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
181
185
|
);
|
|
@@ -672,6 +672,33 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
|
|
|
672
672
|
* @returns {number[] | null} Times relative to the run start, or null when the
|
|
673
673
|
* boundaries cannot serve (missing, or the index is outside them).
|
|
674
674
|
*/
|
|
675
|
+
/**
|
|
676
|
+
* The ffmpeg command as one readable line.
|
|
677
|
+
*
|
|
678
|
+
* Everything is shown as passed except the list of cut times, which is one
|
|
679
|
+
* value per segment — 830 of them on a two-hour film, about 7 KB of log for a
|
|
680
|
+
* single run, repeated on every restart. The count and the two ends say
|
|
681
|
+
* everything the list is ever consulted for: whether cutting was explicit at
|
|
682
|
+
* all, how far it reaches, and where it starts.
|
|
683
|
+
*
|
|
684
|
+
* @param {string[]} args
|
|
685
|
+
* @returns {string}
|
|
686
|
+
*/
|
|
687
|
+
export function describeFfmpegArgs(args) {
|
|
688
|
+
const parts = [];
|
|
689
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
690
|
+
const value = args[index];
|
|
691
|
+
if (value === "-segment_times" && typeof args[index + 1] === "string") {
|
|
692
|
+
const times = args[index + 1].split(",");
|
|
693
|
+
parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
|
|
694
|
+
index += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
parts.push(value);
|
|
698
|
+
}
|
|
699
|
+
return parts.join(" ");
|
|
700
|
+
}
|
|
701
|
+
|
|
675
702
|
export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
676
703
|
if (!Array.isArray(boundaries) || boundaries.length < 2) {
|
|
677
704
|
return null;
|
|
@@ -999,6 +1026,12 @@ export class HlsSessionManager {
|
|
|
999
1026
|
const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
|
|
1000
1027
|
if (readWindowBytes > 0) {
|
|
1001
1028
|
inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
|
|
1029
|
+
// This read, and only this read, follows the viewer. The codec probe and
|
|
1030
|
+
// the keyframe index also go through `/stream`, and they jump between the
|
|
1031
|
+
// first bytes and the last ones — which is indistinguishable, from byte
|
|
1032
|
+
// offsets alone, from someone dragging the slider. Saying so here is
|
|
1033
|
+
// cheaper and more truthful than guessing from the offsets.
|
|
1034
|
+
inputUrl.searchParams.set("reader", "playback");
|
|
1002
1035
|
}
|
|
1003
1036
|
if (!hasDuration) {
|
|
1004
1037
|
logger.warn(
|
|
@@ -2132,7 +2165,7 @@ export class HlsSessionManager {
|
|
|
2132
2165
|
// were verified to handle a copied AC-3 track on this very host, so the
|
|
2133
2166
|
// arguments that run actually received are the missing evidence. One line
|
|
2134
2167
|
// per run, and a run happens at most every few seconds.
|
|
2135
|
-
logger.info(`transcode ${session.id} ffmpeg ${args
|
|
2168
|
+
logger.info(`transcode ${session.id} ffmpeg ${describeFfmpegArgs(args)}`);
|
|
2136
2169
|
|
|
2137
2170
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
2138
2171
|
cwd: session.dirPath,
|
package/services/torrent-pool.js
CHANGED
|
@@ -136,7 +136,11 @@ export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
|
|
|
136
136
|
const chosen = [];
|
|
137
137
|
for (const torrent of torrents) {
|
|
138
138
|
const usage = usageByTorrent?.get?.(torrent);
|
|
139
|
-
|
|
139
|
+
const hasReader = Boolean(usage && usage.size > 0);
|
|
140
|
+
if (hasReader || (torrent?.hurryUntil ?? 0) > now) {
|
|
141
|
+
// Recorded so the policy can tell "nothing is arriving and somebody is
|
|
142
|
+
// waiting" from "nothing is arriving because nobody asked".
|
|
143
|
+
torrent.hasActiveReader = hasReader;
|
|
140
144
|
chosen.push(torrent);
|
|
141
145
|
}
|
|
142
146
|
}
|
|
@@ -176,7 +180,13 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
|
|
|
176
180
|
// iterate the piece array and throw on webtorrent 3.x when a piece is null
|
|
177
181
|
// (deselected / mid-verify), which would crash this timer every cycle.
|
|
178
182
|
const notDone = torrent?.done !== true;
|
|
179
|
-
|
|
183
|
+
// A torrent nobody is reading is not starving, however still its download
|
|
184
|
+
// looks. The encoder is held back once it is far enough ahead of the
|
|
185
|
+
// viewer, and while it is held nothing is requested — measured
|
|
186
|
+
// 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
|
|
187
|
+
// reported as `earn unchoke ... down=0KB/s`, all of them raising the
|
|
188
|
+
// upload at moments when no byte was wanted by anyone.
|
|
189
|
+
const starving = notDone && torrent?.hasActiveReader !== false && downloadSpeed < starvingSpeed;
|
|
180
190
|
if (starving && chokedInterested >= chokedThreshold) {
|
|
181
191
|
const name = typeof torrent?.name === "string" ? torrent.name : "?";
|
|
182
192
|
return {
|
|
@@ -373,6 +383,14 @@ export class TorrentPool {
|
|
|
373
383
|
*/
|
|
374
384
|
#readPositionByTorrent = new Map();
|
|
375
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Edge prefetches currently running, keyed by infoHash and file index, so two
|
|
388
|
+
* callers asking at the same time share one.
|
|
389
|
+
*
|
|
390
|
+
* @type {Map<string, Promise<void>>}
|
|
391
|
+
*/
|
|
392
|
+
#edgePrefetches = new Map();
|
|
393
|
+
|
|
376
394
|
/** Global disk cap in bytes (0 = disabled). */
|
|
377
395
|
#maxDiskBytes = 0;
|
|
378
396
|
|
|
@@ -1024,6 +1042,37 @@ export class TorrentPool {
|
|
|
1024
1042
|
fileIndex,
|
|
1025
1043
|
{ headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000 } = {}
|
|
1026
1044
|
) {
|
|
1045
|
+
if (!torrent || !Array.isArray(torrent.files)) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// Two callers can ask for the same edges at once: the warm-up that starts
|
|
1049
|
+
// when a torrent is picked, and the playback plan a moment later. Reading
|
|
1050
|
+
// the same two pieces twice costs nothing in bandwidth — the torrent
|
|
1051
|
+
// fetches each piece once — but it does open a second pair of readers, each
|
|
1052
|
+
// claiming a window and holding pieces. One is enough.
|
|
1053
|
+
const inFlightKey = `${torrent.infoHash}:${fileIndex}`;
|
|
1054
|
+
const running = this.#edgePrefetches.get(inFlightKey);
|
|
1055
|
+
if (running) {
|
|
1056
|
+
return running;
|
|
1057
|
+
}
|
|
1058
|
+
const prefetch = this.#prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs });
|
|
1059
|
+
this.#edgePrefetches.set(inFlightKey, prefetch);
|
|
1060
|
+
try {
|
|
1061
|
+
return await prefetch;
|
|
1062
|
+
} finally {
|
|
1063
|
+
this.#edgePrefetches.delete(inFlightKey);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The body of {@link prefetchFileEdges}, without the de-duplication.
|
|
1069
|
+
*
|
|
1070
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
1071
|
+
* @param {number} fileIndex
|
|
1072
|
+
* @param {{ headBytes: number, tailBytes: number, timeoutMs: number }} options
|
|
1073
|
+
* @returns {Promise<void>}
|
|
1074
|
+
*/
|
|
1075
|
+
async #prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs }) {
|
|
1027
1076
|
if (!torrent || !Array.isArray(torrent.files)) {
|
|
1028
1077
|
return;
|
|
1029
1078
|
}
|
|
@@ -1125,9 +1174,11 @@ export class TorrentPool {
|
|
|
1125
1174
|
* @param {number} fileIndex
|
|
1126
1175
|
* @param {number} byteStart - Start offset within the file.
|
|
1127
1176
|
* @param {number} [windowBytes] - Unused; kept so callers need not change.
|
|
1128
|
-
* @param {{ wholeFileRead?: boolean }} [options] -
|
|
1129
|
-
* request that carried no byte range, i.e. one that
|
|
1130
|
-
* 0 rather than asking to read from there.
|
|
1177
|
+
* @param {{ wholeFileRead?: boolean, isPlaybackRead?: boolean }} [options] -
|
|
1178
|
+
* `wholeFileRead` marks a request that carried no byte range, i.e. one that
|
|
1179
|
+
* merely opens the file at 0 rather than asking to read from there.
|
|
1180
|
+
* `isPlaybackRead` marks the encoder's input read, the only one that
|
|
1181
|
+
* follows the viewer. See the guards below.
|
|
1131
1182
|
* @returns {void}
|
|
1132
1183
|
*/
|
|
1133
1184
|
prioritizeByteRange(
|
|
@@ -1182,10 +1233,15 @@ export class TorrentPool {
|
|
|
1182
1233
|
const isJump =
|
|
1183
1234
|
previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
|
|
1184
1235
|
if (isJump) {
|
|
1185
|
-
// A jump
|
|
1186
|
-
// else, and the pieces at the new
|
|
1187
|
-
// that are choking us — the same
|
|
1188
|
-
|
|
1236
|
+
// A jump in the read that follows the viewer is a seek. Whatever the
|
|
1237
|
+
// swarm was giving us was for somewhere else, and the pieces at the new
|
|
1238
|
+
// position have to be earned from peers that are choking us — the same
|
|
1239
|
+
// standing start as a fresh torrent. A jump in any OTHER read is the
|
|
1240
|
+
// codec probe or the keyframe index visiting the ends of the file, and
|
|
1241
|
+
// nobody is waiting on those the way a viewer waits on a seek.
|
|
1242
|
+
if (options.isPlaybackRead) {
|
|
1243
|
+
this.#markHurry(torrent, "the viewer moved");
|
|
1244
|
+
}
|
|
1189
1245
|
const percent = ((safeStart / fileLength) * 100).toFixed(1);
|
|
1190
1246
|
logger.info(
|
|
1191
1247
|
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
|
package/test/read-window.test.js
CHANGED
|
@@ -219,7 +219,8 @@ test("criticality marks the window being waited for, not the whole range", async
|
|
|
219
219
|
try {
|
|
220
220
|
const iterator = readFragments({
|
|
221
221
|
torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
|
|
222
|
-
cancellation: { isCancelled: () => false }
|
|
222
|
+
cancellation: { isCancelled: () => false },
|
|
223
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
223
224
|
});
|
|
224
225
|
const pending = iterator.next();
|
|
225
226
|
await new Promise((resolve) => setImmediate(resolve));
|
|
@@ -94,3 +94,28 @@ test("a torrent with no reader still reaches the policy while it is in a hurry",
|
|
|
94
94
|
"a torrent being read still counts, hurry or not"
|
|
95
95
|
);
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
test("a torrent nobody is reading is not treated as starving", () => {
|
|
99
|
+
// The encoder is suspended once it is far enough ahead, and while it is
|
|
100
|
+
// suspended nothing is requested — so the download reads zero without anyone
|
|
101
|
+
// waiting. Measured before this guard: four cycles of 512 -> 50 KB/s in three
|
|
102
|
+
// minutes, every one of them reported as `earn unchoke ... down=0KB/s`.
|
|
103
|
+
const idleButChoked = {
|
|
104
|
+
name: "film.mkv",
|
|
105
|
+
hasActiveReader: false,
|
|
106
|
+
wires: [
|
|
107
|
+
{ amInterested: true, peerChoking: true },
|
|
108
|
+
{ amInterested: true, peerChoking: true }
|
|
109
|
+
],
|
|
110
|
+
downloadSpeed: 0,
|
|
111
|
+
done: false
|
|
112
|
+
};
|
|
113
|
+
assert.equal(decideUploadLimit([idleButChoked], { now: NOW }).bytesPerSec, 50 * 1024);
|
|
114
|
+
|
|
115
|
+
const waiting = { ...idleButChoked, hasActiveReader: true };
|
|
116
|
+
assert.equal(
|
|
117
|
+
decideUploadLimit([waiting], { now: NOW }).bytesPerSec,
|
|
118
|
+
512 * 1024,
|
|
119
|
+
"a reader that IS waiting must still earn unchoke slots"
|
|
120
|
+
);
|
|
121
|
+
});
|