@torrent-tv/proxy 2.81.1 → 2.81.2

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,8 @@
1
+ ## 2.81.2
2
+
3
+ - **Fix**: `disk: 0MB free` on a host with 103 GB. The free space was read from the segments’ own directory, which is made when the first session starts and removed when the proxy stops — so at every start, and after every clean exit, the reading failed and answered zero, which means “no room” to everything downstream. It reads the nearest ancestor that exists; the disk is the same disk either way.
4
+ - **Fix**: The spilled pieces were never registered with the owner of the disk, so it divided nothing and they kept the ceiling they had. The session manager holds no torrent pool — it is handed closures over the thread boundary — and 2.81.0 asked it for a field that does not exist. They arrive the same way everything else from that thread does.
5
+
1
6
  ## 2.81.1
2
7
 
3
8
  - **Fix**: The `disk:` line is actually said. 2.81.0 built the reading and called it from nowhere, so the one thing that can answer "why is there no room" was absent from the log. The owner says it itself, once a pass, in the series beside the memory reading.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.81.1",
3
+ "version": "2.81.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -178,6 +178,15 @@ export async function startProxyServer({
178
178
  // CPU-bound transcode from a download-starved input before downscaling.
179
179
  // What every torrent here has moved, so the proxy can price its own
180
180
  // downloading, hashing and delivery against the machine (roadmap item 7).
181
+ // What the spilled pieces weigh on the torrent thread, and how to tell them
182
+ // their share of the disk. The owner of the disk is on this side, where the
183
+ // segments are; the pieces are on the other.
184
+ spillDisk: typeof torrentPool.allowSpillBytes === "function"
185
+ ? {
186
+ held: () => torrentPool.spilledBytes ?? 0,
187
+ allow: (bytes) => torrentPool.allowSpillBytes(bytes)
188
+ }
189
+ : null,
181
190
  getTorrentTotals: async () => {
182
191
  if (typeof torrentPool.getTorrentTotals !== "function") {
183
192
  return null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file How much room the disk holding a directory has.
3
+ *
4
+ * Separate from the plain `statfs` reading because of one case that is the
5
+ * normal one, not an edge: the directory may not exist yet. The segments live
6
+ * under `os.tmpdir()/torrent-tv-hls`, which is made when the first session is
7
+ * created and removed when the proxy stops — so at every start, and after every
8
+ * clean exit, `statfs` on it fails. Field 2026-09-10: the first reading said
9
+ * `disk: 0MB free` on a host with 103 GB, and zero means "no room" to everything
10
+ * that reads it.
11
+ *
12
+ * The disk is the same disk whether or not that directory has been made yet, so
13
+ * the answer is the nearest ancestor that exists.
14
+ */
15
+
16
+ import { statfs } from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * @param {string} directory
21
+ * @returns {Promise<number | null>} Bytes free, or null where nothing answered.
22
+ */
23
+ export async function freeBytesFor(directory) {
24
+ let at = path.resolve(directory);
25
+ for (let depth = 0; depth < 16; depth += 1) {
26
+ try {
27
+ const stats = await statfs(at);
28
+ return Number(stats.bavail) * Number(stats.bsize);
29
+ } catch {
30
+ const up = path.dirname(at);
31
+ if (up === at) {
32
+ return null;
33
+ }
34
+ at = up;
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -19,14 +19,16 @@ import { DiskSpace } from "./DiskSpace.js";
19
19
  *
20
20
  * @param {object} params
21
21
  * @param {{ root: string, stats: () => { bytes: number } }} params.segmentStore
22
- * @param {{ spilledBytes?: number, allowSpillBytes?: (bytes: number) => unknown }} [params.torrentPool]
22
+ * @param {{ held: () => number, allow: (bytes: number) => unknown }} [params.spill] -
23
+ * The pieces the memory store spills. They live on the torrent thread, so
24
+ * this is a pair of closures over the channel rather than the pool itself.
23
25
  * @param {(directory: string) => Promise<number | null>} params.readFree
24
26
  * @param {{ info: Function, warn?: Function }} [params.logger]
25
27
  * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number, describe: () => string }}
26
28
  * What the segments may hold is asked for rather than pushed: zero until the
27
29
  * first revision, and zero stops growth rather than licensing it.
28
30
  */
29
- export function wireDiskSpace({ segmentStore, torrentPool, readFree, logger }) {
31
+ export function wireDiskSpace({ segmentStore, spill, readFree, logger }) {
30
32
  const space = new DiskSpace({ readFree: () => readFree(segmentStore.root), logger });
31
33
  let segmentBytes = 0;
32
34
  space.register({
@@ -39,16 +41,15 @@ export function wireDiskSpace({ segmentStore, torrentPool, readFree, logger }) {
39
41
  segmentBytes = bytes;
40
42
  }
41
43
  });
42
- if (typeof torrentPool?.allowSpillBytes === "function") {
43
- // The pieces the memory store spills. They live on the torrent thread, so
44
- // the share travels the channel that already carries everything else, and
44
+ if (typeof spill?.allow === "function") {
45
+ // The share travels the channel that already carries everything else, and
45
46
  // the reply says what they hold — one exchange, both directions.
46
47
  space.register({
47
48
  name: "spilled pieces",
48
- held: () => torrentPool.spilledBytes ?? 0,
49
+ held: () => spill.held?.() ?? 0,
49
50
  wanted: () => Number.MAX_SAFE_INTEGER,
50
51
  allow: (bytes) => {
51
- void torrentPool.allowSpillBytes?.(bytes);
52
+ void spill.allow(bytes);
52
53
  }
53
54
  });
54
55
  }
@@ -93,8 +93,8 @@ import { Viewers } from "./viewer/Viewers.js";
93
93
  import { LiveOutputs } from "./output/LiveOutputs.js";
94
94
  import { variantHeightsFor } from "./output/ladder.js";
95
95
  import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
96
- import { readDiskFree } from "./memory-report.js";
97
96
  import { wireDiskSpace } from "./disk/wire.js";
97
+ import { freeBytesFor } from "./disk/free.js";
98
98
 
99
99
  /**
100
100
  * Whether an encoder run died because its INPUT went away, rather than because
@@ -1398,7 +1398,8 @@ export class HlsSessionManager {
1398
1398
  segmentFormatId = undefined,
1399
1399
  stateDir = "",
1400
1400
  segmentStore = null,
1401
- getTorrentTotals}) {
1401
+ getTorrentTotals,
1402
+ spillDisk = null}) {
1402
1403
  this.enabled = Boolean(enabled);
1403
1404
  this.ffmpegBin = ffmpegBin;
1404
1405
  this.keyframeTableBudgetMs = Number.isFinite(keyframeTableBudgetMs) && keyframeTableBudgetMs > 0
@@ -1451,6 +1452,10 @@ export class HlsSessionManager {
1451
1452
  // torrent itself costs the machine (item 7). Optional: a proxy wired
1452
1453
  // without it simply never learns that figure.
1453
1454
  this.getTorrentTotals = typeof getTorrentTotals === "function" ? getTorrentTotals : null;
1455
+ // The spilled pieces, as a pair of closures over the torrent thread: what
1456
+ // they weigh and how to tell them their share. Not the pool — the pool is
1457
+ // on the other side of the thread boundary and this side holds none of it.
1458
+ this.spillDisk = spillDisk;
1454
1459
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
1455
1460
  // software libx264 when no detection result is supplied. May be downgraded
1456
1461
  // to software at runtime if a hardware encode fails.
@@ -1597,8 +1602,8 @@ export class HlsSessionManager {
1597
1602
  // One owner of the disk, and the list of what takes it lives with the owner.
1598
1603
  this.diskSpace = wireDiskSpace({
1599
1604
  segmentStore: this.segmentStore,
1600
- torrentPool: this.torrentPool,
1601
- readFree: readDiskFree,
1605
+ spill: this.spillDisk,
1606
+ readFree: freeBytesFor,
1602
1607
  logger
1603
1608
  });
1604
1609
  // Realtime-budget monitor: only meaningful for the software encoder with a
@@ -148,3 +148,18 @@ test("it says what it decided, every pass", async () => {
148
148
  assert.equal(lines.length, 1, "a pass that decided the shares said nothing about them");
149
149
  assert.match(lines[0], /disk: 100MB free; segments 4MB of 8MB/);
150
150
  });
151
+
152
+ test("the free space is read from the nearest directory that exists", async () => {
153
+ // The segments' directory is made when the first session starts and removed
154
+ // when the proxy stops, so at every start `statfs` on it fails. Field
155
+ // 2026-09-10: the first reading said `disk: 0MB free` on a host with 103 GB,
156
+ // and zero means "no room" to everything that reads it.
157
+ const { freeBytesFor } = await import("../services/disk/free.js");
158
+ const os = await import("node:os");
159
+ const path = await import("node:path");
160
+ const missing = path.join(os.tmpdir(), "no-such-directory-here", "nor-here");
161
+
162
+ const bytes = await freeBytesFor(missing);
163
+
164
+ assert.ok(Number.isFinite(bytes) && bytes > 0, "a directory that does not exist read as no disk");
165
+ });