@torrent-tv/proxy 2.80.9 → 2.80.10
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 +51 -50
- package/services/data-channel-handler.js +2 -4
- package/services/encode/EncodePlan.js +0 -3
- package/services/encode/EncodeRun.js +14 -0
- package/services/hls-session-manager.js +37 -143
- package/services/hwaccel.js +2 -2
- package/services/orchestrators/EncodeOrchestrator.js +726 -726
- package/services/quality/EncodeCost.js +555 -555
- package/test/auto-quality-step.test.js +514 -507
- package/test/contention.test.js +1 -1
- package/test/encode-orchestrator.test.js +1 -2
- package/test/encode-plan-viewers.test.js +18 -19
- package/test/encode-plan.test.js +539 -539
- package/test/held-request-width.test.js +155 -154
- package/test/helpers/encode-run.js +136 -128
- package/test/orchestrator-wired.test.js +16 -11
- package/test/produced-copy-choice.test.js +358 -327
- package/test/segment-serve-wiring.test.js +76 -20
- package/test/stale-request-after-seek.test.js +51 -77
- package/test/tracks-begin-together.test.js +176 -153
- package/test/viewer-outputs.test.js +278 -273
- package/test/behind-head-repair.test.js +0 -261
- /package/services/{contention.js → encode/contention.js} +0 -0
- /package/{test/decode-cost.test.js → test-measured/decode-cost.measured.js} +0 -0
- /package/{test/decode-measurement.test.js → test-measured/decode-measurement.measured.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.80.10
|
|
2
|
+
|
|
3
|
+
- **Fix**: A segment REQUEST no longer moves an encoder. `#repairBehindHead` read the traffic — how many times a number behind the run had been asked for, how many distinct ones, how long ago the first — and armed a restart from it, through six constants of its own. That is a second authority over where encoders go, and it survived the pass that removed the other two only because it lives in the path that answers a file rather than in the plan. What is missing in front of a viewer is stated by the priority map, which is built from where the viewers are and from nothing else, and placing encoders on it is the plan's work; the request is now answered and explained instead of obeyed. 137 lines out of `hls-session-manager.js` and 261 more with the checks of the mechanism it removes. Two checks that had been failing since the day this became true now pass without being touched — a request cannot become the encoder's destination, and it cannot rewrite what the viewer said about themselves.
|
|
4
|
+
- **Fix**: How far behind the run a request may be before it is answered as absent rather than held is asked of the priority map — is anybody coming for that number — instead of being measured against the reach of that repair. An empty map is told apart from a map with nobody in it: the first says it has not been built yet, and answering it as absent would refuse every behind-head request a fresh session ever makes.
|
|
5
|
+
- **Fix**: The clearing-up after a run is given the name of the last piece the run itself reported. Written 2026-09-06 for exactly that and never passed, so it always ran with nothing proven and could only bound itself by the stretch the run had been given.
|
|
6
|
+
- **Chore**: `contention.js` moves into `encode/`, where the thing that reads it lives. In `services/` it was an import from the layer above, which the project's own import rule forbids and had been reporting as an error.
|
|
7
|
+
- **Chore**: Every lint error is gone — twenty-five of them, all mine, all dead code left behind by changes that removed its callers: a constant the drain loop stopped using when it started waiting for the low-water mark, a bound the gap search stopped taking from the demand, two names in a planning pass whose work moved into the loop below, an unused import, and nineteen unused variables in tests.
|
|
8
|
+
- **Chore**: Every test passes — 917 in the ordinary pass and 22 more in a pass of its own, where they belong: `decode-cost` and `decode-measurement` drive real ffmpeg and assert what THIS MACHINE does, so run beside four hundred other tests they measure the load of the run instead. Four failures in one day on 2026-08-31, every one passing alone moments later. They now run last, alone and one at a time. The other thirteen failures were stale in a way that mattered: they described mechanisms this proxy no longer has — a per-viewer claim released by the encoding layer, a shared position written by every seek, a viewer removed for going quiet — and each is now written against what replaced it.
|
|
9
|
+
|
|
1
10
|
## 2.80.9
|
|
2
11
|
|
|
3
12
|
- **Fix**: What the encoding plan believes exists is now a projection of the disk, replaced whole before every decision, instead of a second copy that only ever grew. Readiness was pushed into the coverage map from outside and never taken back: the map could learn that a segment had been made and had no way to learn that its file had gone — discarded with the run that had it open, dropped to make room, or overwritten by a run restarting on it. Field 2026-09-07, twice in one evening: the map claimed all 482 segments of a 48-minute film while the directory held nothing a header could be lifted out of, so every arrangement of encoders scored equally perfect, the only encoder was stopped with the plan's own words — `the film is no worse off without it` — and none was placed again for the rest of the session. The browser asked for the init segment, was never answered, and both sessions ended on `the proxy accepted the request but sent no video`; the second never received a byte (`processedSeconds=0.0 inputBytes=0`). The remedy is not a way to un-mark a segment — a second owner kept in step can fall out of step again — so the map no longer owns readiness at all: `EncodeOrchestrator` asks the segment store, which owns the files, and states the whole answer immediately before the plan reads it. Four checks over a real store and a real directory, two of which fail against the old accumulating behaviour.
|
package/package.json
CHANGED
|
@@ -1,50 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.80.
|
|
4
|
-
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
|
-
"license": "GPL-3.0-or-later",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
|
-
"type": "module",
|
|
10
|
-
"engines": {
|
|
11
|
-
"node": ">=24 <25"
|
|
12
|
-
},
|
|
13
|
-
"bin": {
|
|
14
|
-
"torrent-tv-proxy": "./bin/cli.js"
|
|
15
|
-
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"patch": "npm whoami && npm version patch && npm publish && git push --follow-tags",
|
|
18
|
-
"minor": "npm whoami && npm version minor && npm publish && git push --follow-tags",
|
|
19
|
-
"major": "npm whoami && npm version major && npm publish && git push --follow-tags",
|
|
20
|
-
"start": "node ./bin/cli.js",
|
|
21
|
-
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
|
|
22
|
-
"test": "npm run lint && node --test",
|
|
23
|
-
"graph": "node scripts/render-run-graph.js",
|
|
24
|
-
"lint": "biome lint ."
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"@fastify/
|
|
29
|
-
"@fastify/
|
|
30
|
-
"@
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@torrent-tv/proxy",
|
|
3
|
+
"version": "2.80.10",
|
|
4
|
+
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
|
+
"license": "GPL-3.0-or-later",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24 <25"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"torrent-tv-proxy": "./bin/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"patch": "npm whoami && npm version patch && npm publish && git push --follow-tags",
|
|
18
|
+
"minor": "npm whoami && npm version minor && npm publish && git push --follow-tags",
|
|
19
|
+
"major": "npm whoami && npm version major && npm publish && git push --follow-tags",
|
|
20
|
+
"start": "node ./bin/cli.js",
|
|
21
|
+
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
|
|
22
|
+
"test": "npm run lint && node --test && npm run test:measured",
|
|
23
|
+
"graph": "node scripts/render-run-graph.js",
|
|
24
|
+
"lint": "biome lint .",
|
|
25
|
+
"test:measured": "node --test --test-concurrency=1 test-measured/decode-cost.measured.js test-measured/decode-measurement.measured.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fastify/cors": "^11.2.0",
|
|
29
|
+
"@fastify/helmet": "^13.0.2",
|
|
30
|
+
"@fastify/static": "^10.1.2",
|
|
31
|
+
"@silentbot1/nat-api": "^0.4.9",
|
|
32
|
+
"chalk": "^5.4.1",
|
|
33
|
+
"commander": "^12.1.0",
|
|
34
|
+
"fastify": "^5.8.5",
|
|
35
|
+
"ffmpeg-static": "^5.3.0",
|
|
36
|
+
"franc": "^6.2.0",
|
|
37
|
+
"get-port": "^7.1.0",
|
|
38
|
+
"node-datachannel": "^0.32.0",
|
|
39
|
+
"parse-torrent": "^11.0.23",
|
|
40
|
+
"uint8-util": "2.2.6",
|
|
41
|
+
"webtorrent": "2.8.5",
|
|
42
|
+
"werift": "^0.24.2",
|
|
43
|
+
"ws": "^8.18.2"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "^2.5.7"
|
|
47
|
+
},
|
|
48
|
+
"overrides": {
|
|
49
|
+
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.8"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -274,7 +274,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
274
274
|
// would be cleared a second after the wedged channel set it and the line
|
|
275
275
|
// would print for every second of a 54-minute episode.
|
|
276
276
|
let wedgeSaid = false;
|
|
277
|
-
let lastStuckLogAt = 0;
|
|
277
|
+
let lastStuckLogAt = 0;
|
|
278
278
|
let connection = connections.get(sessionId);
|
|
279
279
|
if (!connection) {
|
|
280
280
|
connection = {
|
|
@@ -481,7 +481,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
481
481
|
`received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
|
|
482
482
|
`rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
|
|
483
483
|
);
|
|
484
|
-
}
|
|
484
|
+
}
|
|
485
485
|
// Roadmap item 11: ask for the packet-level truth the moment the wedge is
|
|
486
486
|
// CERTAIN, not after a chosen delay. The three facts below are not
|
|
487
487
|
// ambiguous together — the queue has not fallen, the accepted-byte
|
|
@@ -1305,5 +1305,3 @@ const SEND_QUEUE_STUCK_MS = 5_000;
|
|
|
1305
1305
|
const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
|
|
1306
1306
|
/** Resume sending once the channel buffer drains to this many bytes. */
|
|
1307
1307
|
const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
|
|
1308
|
-
/** Safety fallback so the send loop cannot deadlock on a missed drain event. */
|
|
1309
|
-
const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;
|
|
@@ -149,9 +149,6 @@ export function planEncoders({
|
|
|
149
149
|
return stops;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// How far a search for a gap needs to look: past the furthest thing anybody
|
|
153
|
-
// is waiting for there is nothing to decide about.
|
|
154
|
-
const demandTo = Math.max(...wanted.map((span) => span.to));
|
|
155
152
|
const untilNeeded = deadlineReaderFor(wanted, segmentSeconds);
|
|
156
153
|
// Segments produced per second, from the fastest measured encoder here.
|
|
157
154
|
// Seconds of film per second, divided by the film one piece holds.
|
|
@@ -295,6 +295,20 @@ export class EncodeRun {
|
|
|
295
295
|
return this.head - 1;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The last piece this run named while it was running normally.
|
|
300
|
+
*
|
|
301
|
+
* Its own statement that a piece is whole, and the only thing that can say so:
|
|
302
|
+
* a piece cut short still decodes, so its contents cannot be asked. Whatever
|
|
303
|
+
* lies beyond this name was open when the run ended, and that is what the
|
|
304
|
+
* clearing-up after a run reads.
|
|
305
|
+
*
|
|
306
|
+
* @returns {string | null}
|
|
307
|
+
*/
|
|
308
|
+
get provenName() {
|
|
309
|
+
return this.#provenName;
|
|
310
|
+
}
|
|
311
|
+
|
|
298
312
|
/** @returns {number[]} */
|
|
299
313
|
get produced() {
|
|
300
314
|
return [...this.#produced].sort((left, right) => left - right);
|
|
@@ -21,7 +21,7 @@ import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
23
|
import { availableShareFrom } from "./available-share.js";
|
|
24
|
-
import { contentionPenalty } from "./contention.js";
|
|
24
|
+
import { contentionPenalty } from "./encode/contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
26
|
import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
|
|
27
27
|
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
@@ -468,21 +468,16 @@ const SEEK_SETTLE_MS = 300;
|
|
|
468
468
|
// has waited: repetition is the player saying it still needs this exact
|
|
469
469
|
// segment, while a delay only says time has passed. The floor below stays as a
|
|
470
470
|
// last guard against acting on a single stray poll.
|
|
471
|
-
const BEHIND_HEAD_REPAIR_MIN_ASKS = 2;
|
|
472
471
|
// More distinct indices than this behind the head at once is the player
|
|
473
472
|
// scanning the playlist rather than waiting for a frame.
|
|
474
|
-
const BEHIND_HEAD_SCAN_INDICES = 3;
|
|
475
473
|
// The window the count above is taken over. A player's scan lands inside half a
|
|
476
474
|
// second (field log 2026-08-02); a viewer waiting asks every few seconds.
|
|
477
|
-
const BEHIND_HEAD_SCAN_WINDOW_MS = 2_000;
|
|
478
|
-
const BEHIND_HEAD_REPAIR_MS = 400;
|
|
479
475
|
// How far behind the run a request may be and still be treated as the encoder
|
|
480
476
|
// standing in the wrong place rather than as a player scanning the playlist. A
|
|
481
477
|
// misplaced run is out by at most the buffer the player was holding — measured
|
|
482
478
|
// 2026-08-11 at 14 segments — while a scan probe is out by anything at all.
|
|
483
479
|
// Generous against that measurement, and far short of the hundreds of segments
|
|
484
480
|
// a scan reaches.
|
|
485
|
-
const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
|
|
486
481
|
// How far the accounting of a backward restart looks for work about to be done
|
|
487
482
|
// twice. It runs on the restart path and a session an hour in has thousands of
|
|
488
483
|
// segments; the figure is for a comparison, not an inventory.
|
|
@@ -3798,8 +3793,6 @@ export class HlsSessionManager {
|
|
|
3798
3793
|
}
|
|
3799
3794
|
byOutput.set(address, [...(byOutput.get(address) ?? []), session]);
|
|
3800
3795
|
}
|
|
3801
|
-
const staleAfterMs = this.presenceStaleAfterMs();
|
|
3802
|
-
const now = Date.now();
|
|
3803
3796
|
for (const [address, sessions] of byOutput) {
|
|
3804
3797
|
// From the TIMELINE, which is where how a file is cut has lived since
|
|
3805
3798
|
// 2.76.0. Read off the session it left, this was `undefined` on every
|
|
@@ -5485,7 +5478,6 @@ export class HlsSessionManager {
|
|
|
5485
5478
|
// for the seek that should move the encoder. It also stops the map growing
|
|
5486
5479
|
// for the life of a session.
|
|
5487
5480
|
session.firstWantedAt = new Map();
|
|
5488
|
-
session.behindHeadAsks = new Map();
|
|
5489
5481
|
// This attempt, so that a newer one can tell it has been overtaken. It used
|
|
5490
5482
|
// to be a counter compared against a copy of itself; the attempt is a thing,
|
|
5491
5483
|
// and comparing the thing says the same without a number to keep in step.
|
|
@@ -5948,16 +5940,6 @@ export class HlsSessionManager {
|
|
|
5948
5940
|
if (!session.firstWantedAt.has(index)) {
|
|
5949
5941
|
session.firstWantedAt.set(index, Date.now());
|
|
5950
5942
|
}
|
|
5951
|
-
// How often each index behind the run has been asked for, and how many
|
|
5952
|
-
// distinct ones there are. The repair reads both: one index asked twice is
|
|
5953
|
-
// a viewer waiting, a dozen asked once each is the player scanning. Kept
|
|
5954
|
-
// only for what is behind the head — everything ahead is ordinary
|
|
5955
|
-
// read-ahead — and cleared with each run, like the record above.
|
|
5956
|
-
if (index < (earliestRunStart(session) ?? 0)) {
|
|
5957
|
-
session.behindHeadAsks ??= new Map();
|
|
5958
|
-
const asked = session.behindHeadAsks.get(index);
|
|
5959
|
-
session.behindHeadAsks.set(index, { count: (asked?.count ?? 0) + 1, at: Date.now() });
|
|
5960
|
-
}
|
|
5961
5943
|
if (!session || session.state === "disposed" || index < 0) {
|
|
5962
5944
|
return;
|
|
5963
5945
|
}
|
|
@@ -6003,7 +5985,17 @@ export class HlsSessionManager {
|
|
|
6003
5985
|
// should move the encoder. Only a request still unanswerable after that is
|
|
6004
5986
|
// repaired here.
|
|
6005
5987
|
if (index < head) {
|
|
6006
|
-
|
|
5988
|
+
// SAID, NOT ACTED ON. What is missing in front of a viewer is stated by
|
|
5989
|
+
// the priority map, and putting encoders on it is the plan's work. This
|
|
5990
|
+
// used to move the encoder itself, from a segment REQUEST — a second
|
|
5991
|
+
// authority over where encoders go, with six chosen constants of its own,
|
|
5992
|
+
// and it survived the pass that removed the other two because it lives in
|
|
5993
|
+
// the path that answers a file rather than in the plan.
|
|
5994
|
+
this.#explainHold(
|
|
5995
|
+
session,
|
|
5996
|
+
session.segmentFormat.segmentFileName(index),
|
|
5997
|
+
`it is behind the run (#${head}); where the viewers are is what places encoders`
|
|
5998
|
+
);
|
|
6007
5999
|
return;
|
|
6008
6000
|
}
|
|
6009
6001
|
// Circuit breaker: this exact target has already failed MAX_FAILED_STARTS
|
|
@@ -6037,120 +6029,6 @@ export class HlsSessionManager {
|
|
|
6037
6029
|
// See research/hls-seek-prior-art-2026-08-02.md.
|
|
6038
6030
|
}
|
|
6039
6031
|
|
|
6040
|
-
/**
|
|
6041
|
-
* Move the encoder back to a segment it can no longer produce.
|
|
6042
|
-
*
|
|
6043
|
-
* A run only ever goes forward from where it began, so a request below that
|
|
6044
|
-
* point is not a claim the run may yet reach — it is a hole, and holding it
|
|
6045
|
-
* holds it for ever. Measured 2026-08-11: a run placed at #770 while the
|
|
6046
|
-
* player needed #757 held that request for two minutes forty-one, producing
|
|
6047
|
-
* 409 s of video nobody had asked for.
|
|
6048
|
-
*
|
|
6049
|
-
* Deliberately narrow, because moving the encoder from a segment REQUEST is
|
|
6050
|
-
* exactly what this codebase removed once already: a player that cannot get
|
|
6051
|
-
* what it wants scans the playlist, and those probes are scattered across the
|
|
6052
|
-
* whole file (field log: #178, #681, #725, #807, #74, #245, #387 within half
|
|
6053
|
-
* a second). Steering on the lowest of them put the encoder at the start of
|
|
6054
|
-
* the film and left the viewer's own requests unreachable ahead of it.
|
|
6055
|
-
*
|
|
6056
|
-
* What separates the two: a run placed wrongly is out by at most the buffer
|
|
6057
|
-
* the player had — 14 segments in the measured case — while a scan probe is
|
|
6058
|
-
* out by anything at all. So only a request within reach behind the head is
|
|
6059
|
-
* repaired; the rest are what they always were, claims that answer 503.
|
|
6060
|
-
*
|
|
6061
|
-
* @param {HlsSession} session
|
|
6062
|
-
* @param {number} index - The segment being held.
|
|
6063
|
-
* @param {number} head - Where the current run begins.
|
|
6064
|
-
* @returns {void}
|
|
6065
|
-
*/
|
|
6066
|
-
#repairBehindHead(session, index, head) {
|
|
6067
|
-
if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
|
|
6068
|
-
return;
|
|
6069
|
-
}
|
|
6070
|
-
// Nothing is encoding: a rung the viewer has switched away from is left
|
|
6071
|
-
// exactly so, and its held requests must not bring its encoder back.
|
|
6072
|
-
if (!processCanBeSignalled(runStateOf(session))) {
|
|
6073
|
-
return;
|
|
6074
|
-
}
|
|
6075
|
-
// A seek already settling is about to move the encoder to where the VIEWER
|
|
6076
|
-
// said they are. That statement outranks anything inferred here.
|
|
6077
|
-
if (session.seekSettleTimer != null) {
|
|
6078
|
-
return;
|
|
6079
|
-
}
|
|
6080
|
-
// And it outranks it AFTERWARDS too, which is what was missing. The guard
|
|
6081
|
-
// above only holds while the settle timer is armed — a second later it is
|
|
6082
|
-
// gone, and a request the browser issued BEFORE the seek is then treated as
|
|
6083
|
-
// fresh evidence. Field 2026-08-17: a seek to 2083.4 s put both runs at
|
|
6084
|
-
// #373, a request for #371 from before it arrived a second afterwards, and
|
|
6085
|
-
// this repair moved the encoder to #370 — three segments behind the viewer,
|
|
6086
|
-
// who waited for it to come back. A request BEHIND what the viewer
|
|
6087
|
-
// themselves reported cannot be describing where they are.
|
|
6088
|
-
const reportedSeconds = Number(session.viewerReportedSeconds);
|
|
6089
|
-
if (Number.isFinite(reportedSeconds)) {
|
|
6090
|
-
const reportedIndex = this.#segmentIndexForTime(session, reportedSeconds);
|
|
6091
|
-
if (index < reportedIndex) {
|
|
6092
|
-
this.#explainHold(
|
|
6093
|
-
session,
|
|
6094
|
-
session.segmentFormat.segmentFileName(index),
|
|
6095
|
-
`it is behind #${reportedIndex}, where the viewer said they are — answered, not obeyed`
|
|
6096
|
-
);
|
|
6097
|
-
return;
|
|
6098
|
-
}
|
|
6099
|
-
}
|
|
6100
|
-
// What separates a request the viewer is waiting for from the player
|
|
6101
|
-
// scanning the playlist is not TIME but what else it is asking for. On a
|
|
6102
|
-
// seek hls.js fires dozens of DIFFERENT indices within half a second (field
|
|
6103
|
-
// log: #178, #681, #725, #807, #74, #245, #387) and abandons them all; a
|
|
6104
|
-
// viewer waiting for audio asks for the SAME one, over and over, because it
|
|
6105
|
-
// is the only thing that will let playback continue.
|
|
6106
|
-
//
|
|
6107
|
-
// So: this index has been asked for at least twice, and it is the only
|
|
6108
|
-
// thing behind the head being asked for. Both are facts about the traffic,
|
|
6109
|
-
// available at once, where a delay is a guess about it — and it was three
|
|
6110
|
-
// seconds of the twenty a track change cost on 2026-08-15.
|
|
6111
|
-
const asked = session.behindHeadAsks?.get(index)?.count ?? 0;
|
|
6112
|
-
if (asked < BEHIND_HEAD_REPAIR_MIN_ASKS) {
|
|
6113
|
-
return;
|
|
6114
|
-
}
|
|
6115
|
-
// Counted over a WINDOW, not over the run: a scan is many indices at once,
|
|
6116
|
-
// while the same map left to accumulate would eventually hold every
|
|
6117
|
-
// behind-head request a long run ever saw and switch the repair off for
|
|
6118
|
-
// good.
|
|
6119
|
-
const scanSince = Date.now() - BEHIND_HEAD_SCAN_WINDOW_MS;
|
|
6120
|
-
let distinctBehind = 0;
|
|
6121
|
-
for (const record of session.behindHeadAsks?.values() ?? []) {
|
|
6122
|
-
if (record.at >= scanSince) {
|
|
6123
|
-
distinctBehind += 1;
|
|
6124
|
-
}
|
|
6125
|
-
}
|
|
6126
|
-
if (distinctBehind > BEHIND_HEAD_SCAN_INDICES) {
|
|
6127
|
-
// A scan, not a wait. Moving the encoder to one of these is moving it to
|
|
6128
|
-
// a number the player picked at random.
|
|
6129
|
-
return;
|
|
6130
|
-
}
|
|
6131
|
-
const wantedAt = session.firstWantedAt?.get(index);
|
|
6132
|
-
if (!Number.isFinite(wantedAt) || Date.now() - wantedAt < BEHIND_HEAD_REPAIR_MS) {
|
|
6133
|
-
return;
|
|
6134
|
-
}
|
|
6135
|
-
const target = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
|
|
6136
|
-
// The breaker has already refused this target repeatedly. Re-arming for it
|
|
6137
|
-
// would log and re-arm on every poll for as long as the request is held,
|
|
6138
|
-
// and move nothing.
|
|
6139
|
-
if (target === session.failedStartAt && session.failedStartCount >= MAX_FAILED_STARTS) {
|
|
6140
|
-
return;
|
|
6141
|
-
}
|
|
6142
|
-
logger.warn(
|
|
6143
|
-
`transcode ${session.id} segment #${index} is behind the run (#${head}) and has waited ` +
|
|
6144
|
-
`${Date.now() - wantedAt}ms — nothing this run does can produce it; moving the encoder there`
|
|
6145
|
-
);
|
|
6146
|
-
// Through the same settle a viewer's own seek goes through, so a burst of
|
|
6147
|
-
// behind-head requests produces one restart and not one each.
|
|
6148
|
-
session.seekTarget = target;
|
|
6149
|
-
session.seekFirstFarAt = Date.now();
|
|
6150
|
-
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
|
|
6151
|
-
session.seekSettleTimer.unref?.();
|
|
6152
|
-
}
|
|
6153
|
-
|
|
6154
6032
|
/**
|
|
6155
6033
|
* Fire a settled server-side seek: restart the encoder once at the target
|
|
6156
6034
|
* recorded during the settle window. Enforces the restart cooldown as a
|
|
@@ -8045,8 +7923,13 @@ export class HlsSessionManager {
|
|
|
8045
7923
|
// made — so the piece this one had open must not be left looking like one
|
|
8046
7924
|
// of them. Not awaited: the caller's own work does not depend on it, and
|
|
8047
7925
|
// the wait is for a process that has already been told to go.
|
|
7926
|
+
// WHAT THE RUN ITSELF NAMED, which is the only thing that can say a piece
|
|
7927
|
+
// is whole — a piece cut short still decodes. Written 2026-09-06 and never
|
|
7928
|
+
// passed from here, so the clearing-up ran with nothing proven and fell
|
|
7929
|
+
// back to bounding itself by the run's declared stretch alone.
|
|
7930
|
+
const provenName = typeof run.provenName === "string" ? run.provenName : null;
|
|
8048
7931
|
void waitForChildExit(ffmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() =>
|
|
8049
|
-
this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan)
|
|
7932
|
+
this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan, provenName)
|
|
8050
7933
|
);
|
|
8051
7934
|
}
|
|
8052
7935
|
logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
|
|
@@ -9940,7 +9823,7 @@ export class HlsSessionManager {
|
|
|
9940
9823
|
* @param {string | null | undefined} runDirPath
|
|
9941
9824
|
* @returns {Promise<void>}
|
|
9942
9825
|
*/
|
|
9943
|
-
async #discardUnfinishedPiece(session, runDirPath, within = null) {
|
|
9826
|
+
async #discardUnfinishedPiece(session, runDirPath, within = null, provenName = null) {
|
|
9944
9827
|
const canJudgeTracks =
|
|
9945
9828
|
typeof session.segmentFormat?.hasEveryTrack === "function" &&
|
|
9946
9829
|
session.initBytes &&
|
|
@@ -9956,7 +9839,8 @@ export class HlsSessionManager {
|
|
|
9956
9839
|
: raw;
|
|
9957
9840
|
return session.segmentFormat.hasEveryTrack(bytes, session.initBytes);
|
|
9958
9841
|
}
|
|
9959
|
-
: null
|
|
9842
|
+
: null,
|
|
9843
|
+
provenName
|
|
9960
9844
|
);
|
|
9961
9845
|
if (removed !== null) {
|
|
9962
9846
|
// Removed on purpose, so the index must not go on answering with it.
|
|
@@ -9991,21 +9875,31 @@ export class HlsSessionManager {
|
|
|
9991
9875
|
// failed did the player move to the segment it actually needed — 63 s of
|
|
9992
9876
|
// spinner after a track that had been made ready in 7.
|
|
9993
9877
|
//
|
|
9994
|
-
//
|
|
9995
|
-
//
|
|
9996
|
-
//
|
|
9997
|
-
//
|
|
9878
|
+
// WHETHER ANYBODY IS COMING FOR IT, asked of the priority map rather than
|
|
9879
|
+
// measured in segments. The distance used to decide, against the reach of
|
|
9880
|
+
// a repair that moved the encoder from a request — both are gone. A
|
|
9881
|
+
// number inside somebody's zone will be made, so holding it is holding it
|
|
9882
|
+
// for a viewer; a number in nobody's zone will not, and holding it spends
|
|
9883
|
+
// the player's patience for nothing.
|
|
9884
|
+
// AN EMPTY MAP IS NOT AN ANSWER. It says the map has not been built yet —
|
|
9885
|
+
// a session created a moment ago, before the first pass — and that is a
|
|
9886
|
+
// different thing from "nobody is coming", which is what this refusal
|
|
9887
|
+
// needs. Told apart, because conflating them answers every behind-head
|
|
9888
|
+
// request as absent for as long as a fresh session has no map.
|
|
9889
|
+
const zones = this.encodeOrchestrator.demand.mapOn(session.outputKey ?? "");
|
|
9890
|
+
const nobodyIsComing = zones.length > 0 &&
|
|
9891
|
+
!zones.some((zone) => requestedIndex >= zone.from && requestedIndex <= zone.to);
|
|
9998
9892
|
if (
|
|
9999
9893
|
Number.isFinite(requestedIndex) &&
|
|
10000
9894
|
requestedIndex < (earliestRunStart(session) ?? 0) &&
|
|
10001
|
-
|
|
9895
|
+
nobodyIsComing &&
|
|
10002
9896
|
liveRunsOf(session).length > 0 &&
|
|
10003
9897
|
session.seekTarget == null &&
|
|
10004
9898
|
session.seekSettleTimer == null
|
|
10005
9899
|
) {
|
|
10006
9900
|
logger.info(
|
|
10007
9901
|
`transcode ${session.id} segment #${requestedIndex} is ${(earliestRunStart(session) ?? 0) - requestedIndex} ` +
|
|
10008
|
-
|
|
9902
|
+
"segments behind the run and in nobody's zone; answered as absent rather than held"
|
|
10009
9903
|
);
|
|
10010
9904
|
return { kind: "not-found" };
|
|
10011
9905
|
}
|
package/services/hwaccel.js
CHANGED
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
|
-
import { mkdtempSync, readdirSync, rmSync, statSync
|
|
24
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
25
25
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
26
26
|
import os from "node:os";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { fitDecodeCost } from "./decode-cost-fit.js";
|
|
29
|
-
import { penaltiesFrom } from "./contention.js";
|
|
29
|
+
import { penaltiesFrom } from "./encode/contention.js";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import {
|
|
32
32
|
parseFfmpegBitrateKbps,
|