@torrent-tv/proxy 2.81.0 → 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 +9 -0
- package/package.json +1 -1
- package/server.js +9 -0
- package/services/disk/DiskSpace.js +4 -0
- package/services/disk/free.js +38 -0
- package/services/disk/wire.js +8 -7
- package/services/hls-session-manager.js +9 -4
- package/test/disk-space.test.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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
|
+
|
|
6
|
+
## 2.81.1
|
|
7
|
+
|
|
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.
|
|
9
|
+
|
|
1
10
|
## 2.81.0
|
|
2
11
|
|
|
3
12
|
- **Fix**: EVERY ENCODER RAN TWICE, ON EVERY RUN, SINCE 2026-09-04. Building a run and starting it were two acts, so two owners each performed the second: the session manager built a run and started it, handed it back, and the orchestrator started it again. Field logs of 08-10 September: 207 runs against 414 spawns, no exception. Only the second process was reachable — this line overwrote the reference to the first — so a stop killed one and the other ran on, measured 105 seconds past its own run's death, with eleven processes writing at once on a four-core host whose budget said three. It doubled the processor, doubled the readers of the piece store (which is the pinned-piece deadlock's own trigger), and put two writers on one file name, defeating the rename 2.80.19 had just introduced: 1105 `could not publish … ENOENT` and a fatal `bufferAppendError` on both tracks at once under 2.80.19 itself.
|
package/package.json
CHANGED
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;
|
|
@@ -114,6 +114,10 @@ export class DiskSpace {
|
|
|
114
114
|
for (const [position, consumer] of consumers.entries()) {
|
|
115
115
|
consumer.allow(shares[position]);
|
|
116
116
|
}
|
|
117
|
+
// SAID, every pass, in the series beside the memory reading. "Why is there
|
|
118
|
+
// no room" was a question no log could answer: the ceilings were worked out
|
|
119
|
+
// in three places and not one of them was printed beside the others.
|
|
120
|
+
this.#logger?.info?.(this.describe());
|
|
117
121
|
return { freeBytes: this.#freeBytes, allowanceBytes: allowance, shares: this.#last };
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -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
|
+
}
|
package/services/disk/wire.js
CHANGED
|
@@ -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 {{
|
|
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,
|
|
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
|
|
43
|
-
// The
|
|
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: () =>
|
|
49
|
+
held: () => spill.held?.() ?? 0,
|
|
49
50
|
wanted: () => Number.MAX_SAFE_INTEGER,
|
|
50
51
|
allow: (bytes) => {
|
|
51
|
-
void
|
|
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
|
-
|
|
1601
|
-
readFree:
|
|
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
|
package/test/disk-space.test.js
CHANGED
|
@@ -136,3 +136,30 @@ test("it says what the disk has and what each claimant may hold", async () => {
|
|
|
136
136
|
await space.revise();
|
|
137
137
|
assert.match(space.describe(), /disk: 100MB free; segments 4MB of 8MB/);
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
test("it says what it decided, every pass", async () => {
|
|
141
|
+
const lines = [];
|
|
142
|
+
const space = new DiskSpace({
|
|
143
|
+
readFree: async () => 100 * MEGABYTE,
|
|
144
|
+
logger: { info: (line) => lines.push(line) }
|
|
145
|
+
});
|
|
146
|
+
space.register(consumerOf("segments", 4 * MEGABYTE, 8 * MEGABYTE).consumer);
|
|
147
|
+
await space.revise();
|
|
148
|
+
assert.equal(lines.length, 1, "a pass that decided the shares said nothing about them");
|
|
149
|
+
assert.match(lines[0], /disk: 100MB free; segments 4MB of 8MB/);
|
|
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
|
+
});
|