@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 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.9",
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
- "dependencies": {
27
- "@fastify/cors": "^11.2.0",
28
- "@fastify/helmet": "^13.0.2",
29
- "@fastify/static": "^10.1.2",
30
- "@silentbot1/nat-api": "^0.4.9",
31
- "chalk": "^5.4.1",
32
- "commander": "^12.1.0",
33
- "fastify": "^5.8.5",
34
- "ffmpeg-static": "^5.3.0",
35
- "franc": "^6.2.0",
36
- "get-port": "^7.1.0",
37
- "node-datachannel": "^0.32.0",
38
- "parse-torrent": "^11.0.23",
39
- "uint8-util": "2.2.6",
40
- "webtorrent": "2.8.5",
41
- "werift": "^0.24.2",
42
- "ws": "^8.18.2"
43
- },
44
- "devDependencies": {
45
- "@biomejs/biome": "^2.5.7"
46
- },
47
- "overrides": {
48
- "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.8"
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
- this.#repairBehindHead(session, index, head);
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
- // Narrow on purpose. A request behind the head is USUALLY temporary: the
9995
- // repair moves the encoder back for anything within its reach, and a
9996
- // reported seek is about to move it anyway. Refusing those was 2.14.1,
9997
- // and it left a viewer retrying a 404 for ever.
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
- (earliestRunStart(session) ?? 0) - requestedIndex > BEHIND_HEAD_REPAIR_MAX_SEGMENTS &&
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
- `segments behind the run and beyond the repair's reach; answered as absent rather than held`
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
  }
@@ -21,12 +21,12 @@
21
21
  */
22
22
 
23
23
  import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
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,