@torrent-tv/proxy 2.95.8 → 2.95.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/bin/cli.js CHANGED
@@ -24,7 +24,7 @@ import { createTunnelClient } from "../services/transport/tunnel-client.js";
24
24
  import { createWebRtcManager } from "../services/transport/webrtc-manager.js";
25
25
  import { createDataChannelHandler } from "../services/transport/data-channel-handler.js";
26
26
  import { dumpsToRemove, pruneCoreDumps } from "../services/storage/core-dumps.js";
27
- import { availableMemoryBytes } from "../services/storage/machine-memory.js";
27
+ import { availableMemory } from "../services/storage/machine-memory.js";
28
28
  import { Diagnostics } from "../services/storage/Diagnostics.js";
29
29
  import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/transport/packet-witness.js";
30
30
  import { createUsrsctpStateReader } from "../services/transport/usrsctp-state.js";
@@ -586,7 +586,7 @@ try {
586
586
  const encode = started?.outputParts?.admission?.headroom?.() ?? null;
587
587
  return {
588
588
  metrics: {
589
- ...collectHealthMetrics({ availableMemoryBytes }),
589
+ ...collectHealthMetrics({ availableMemory }),
590
590
  ...(encode ? { encodeSpeedX: encode.encodeSpeedX, encodeOccupiedCostSec: encode.occupiedCostSec } : {})
591
591
  },
592
592
  holds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.8",
3
+ "version": "2.95.10",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -494,7 +494,7 @@ export async function startProxyServer({
494
494
  });
495
495
  const indexMemory = new IndexMemory({
496
496
  reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
497
- changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); }
497
+ changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
498
498
  });
499
499
  const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
500
500
  const record = sourceRegistry.get(sourceKey);
@@ -531,6 +531,10 @@ export async function startProxyServer({
531
531
  onReadResult: async (statement, result, revision) => {
532
532
  if (revision.demand !== downloadMaps.epoch(sourceKey, fileIndex)) return;
533
533
  if (demand && !downloadMaps.wantsInterval(sourceKey, fileIndex, demand)) return;
534
+ if (statement === "keyframes" && result.kind === "result") {
535
+ keyframeTables.learn(params, { times: result.value?.times ?? null, tolerance: result.value?.tolerance ?? 0,
536
+ format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
537
+ }
534
538
  const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
535
539
  await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
536
540
  ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
@@ -5,6 +5,7 @@ import { admitOriginalInput } from "./OriginalInput.js";
5
5
  export class EncodeInputs {
6
6
  #requests = new Map();
7
7
  #revision = 0;
8
+ #memoryRevision = 0;
8
9
  #held = 0;
9
10
  #allowed = 0;
10
11
  #wanted = new Map();
@@ -43,7 +44,13 @@ export class EncodeInputs {
43
44
  allow(bytes) {
44
45
  const changed = this.#allowed !== Math.max(0, bytes);
45
46
  this.#allowed = Math.max(0, bytes);
46
- if (changed) this.#retryMemory();
47
+ if (changed) this.memoryChanged();
48
+ }
49
+
50
+ /** Metadata admission and input admission can change independently. */
51
+ memoryChanged() {
52
+ this.#memoryRevision++;
53
+ this.#retryMemory();
47
54
  }
48
55
 
49
56
  #retryMemory() {
@@ -112,6 +119,7 @@ export class EncodeInputs {
112
119
  #prepare(request) {
113
120
  request.pending = true;
114
121
  request.revision = this.#revision;
122
+ request.memoryRevision = this.#memoryRevision;
115
123
  request.promise = (async () => {
116
124
  const resolved = await this.#resolve(request.output, request.from, request.to);
117
125
  if (this.#requests.get(request.key) !== request) return;
@@ -133,7 +141,7 @@ export class EncodeInputs {
133
141
  return () => {
134
142
  if (!released) {
135
143
  released = true; this.#held -= bytes;
136
- queueMicrotask(() => this.#retryMemory());
144
+ queueMicrotask(() => this.memoryChanged());
137
145
  }
138
146
  };
139
147
  }
@@ -149,7 +157,9 @@ export class EncodeInputs {
149
157
  }).finally(() => {
150
158
  request.pending = false;
151
159
  if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
152
- if (this.#requests.get(request.key) === request && request.revision !== this.#revision &&
160
+ const changed = request.result?.kind === "needs-memory"
161
+ ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision;
162
+ if (this.#requests.get(request.key) === request && changed &&
153
163
  request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
154
164
  });
155
165
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { KeyframeTable } from "./container/KeyframeTable.js";
4
4
  import { isUnavailable } from "./container/unavailable.js";
5
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
5
6
  import { logger as defaultLogger } from "../../utils/logger.js";
6
7
 
7
8
  export class KeyframeTables {
@@ -123,6 +124,8 @@ export class KeyframeTables {
123
124
  `keyframe index "${logName}": not downloaded yet after ${Date.now() - startedMs}ms — ` +
124
125
  "read again when pieces of the file arrive"
125
126
  );
127
+ } else if (error instanceof IndexMemoryUnavailable) {
128
+ this.#logger.info(`keyframe index "${logName}": waiting for ${error.bytes} additional allocation bytes`);
126
129
  } else {
127
130
  this.#logger.warn(
128
131
  `keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
@@ -183,7 +186,10 @@ export class KeyframeTables {
183
186
  const table = await this.read(params);
184
187
  return { table, arrived: table.answered };
185
188
  } catch (error) {
186
- if (isUnavailable(error)) return { table: this.of(params), arrived: false };
189
+ if (isUnavailable(error) || error instanceof IndexMemoryUnavailable) {
190
+ const table = this.of(params);
191
+ return { table, arrived: table.answered };
192
+ }
187
193
  throw error;
188
194
  }
189
195
  }
@@ -11,6 +11,7 @@ import { ContainerTrack } from "../tracks/ContainerTrack.js";
11
11
  import { PacketIndex } from "./PacketIndex.js";
12
12
  import { openDmlPackets } from "./avi-open-dml.js";
13
13
  import { isUnavailable } from "./unavailable.js";
14
+ import { IndexMemoryUnavailable } from "./memory-unavailable.js";
14
15
  import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
15
16
  import { MpegElementaryIndex } from "./mpeg-elementary-index.js";
16
17
  import { RetainedReads } from "./RetainedReads.js";
@@ -25,6 +26,7 @@ export class AviContainer extends Container {
25
26
  #tracks = null;
26
27
  #packets = null;
27
28
  #scan = null;
29
+ #indexed = null;
28
30
  #declarations = null;
29
31
  #workTags = null;
30
32
 
@@ -108,7 +110,7 @@ export class AviContainer extends Container {
108
110
  }
109
111
 
110
112
 
111
- packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
113
+ packetIndexBytes() { return (this.#packets ?? this.#scan?.index ?? this.#indexed?.index)?.allocatedBytes() ?? 0; }
112
114
 
113
115
  static detect(head) {
114
116
  return isAvi(head);
@@ -221,6 +223,7 @@ export class AviContainer extends Container {
221
223
  async readPacketIndex(interval) {
222
224
  if (this.#packets) return this.#packets;
223
225
  if (this.#scan) return this.#readUnindexed(interval);
226
+ if (this.#indexed) return this.#readIndexed();
224
227
  const tracks = await this.readTracks();
225
228
  const { streams } = await this.#readHeaders();
226
229
  if (streams.some(stream => stream.indexChunks.length)) {
@@ -278,20 +281,30 @@ export class AviContainer extends Container {
278
281
  }
279
282
  if ((table.end - table.start) % 16 !== 0) throw new Error("AVI packet index entry is truncated.");
280
283
  const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
281
- try {
282
284
  const clocks = new Map();
283
285
  const timings = new Map();
286
+ if (tracks.some(track => ["video", "audio", "subtitle"].includes(track.type) && !(track.timeBase > 0))) {
287
+ throw new Error("AVI stream time base is invalid.");
288
+ }
284
289
  for (const track of tracks) {
285
290
  if (!["video", "audio", "subtitle"].includes(track.type)) continue;
286
- if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
287
291
  index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
288
292
  clocks.set(track.trackNumber, track.startTimeSeconds);
289
293
  }
290
- let offsetBase = null;
291
- for (let at = table.start; at < table.end; at += 16) {
294
+ this.#indexed = { index, clocks, timings, tracks, movi, table, offsetBase: null, at: table.start };
295
+ return this.#readIndexed();
296
+ }
297
+
298
+ async #readIndexed() {
299
+ const state = this.#indexed;
300
+ const { index, clocks, timings, tracks, movi, table } = state;
301
+ try {
302
+ index.flushPending();
303
+ for (; state.at < table.end;) {
304
+ const at = state.at;
292
305
  const entry = await this.readRange(at, at + 15);
293
306
  const chunkId = entry.toString("ascii", 0, 4);
294
- if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) continue;
307
+ if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) { state.at += 16; continue; }
295
308
  const id = Number(chunkId.slice(0, 2));
296
309
  const track = tracks.find(track => track.trackNumber === id);
297
310
  if (!track || !clocks.has(id)) throw new Error("AVI packet refers to an undeclared stream.");
@@ -300,18 +313,19 @@ export class AviContainer extends Container {
300
313
  const length = entry.readUInt32LE(12);
301
314
  if (length === 0) {
302
315
  if (track.type === "video") { index.extendLastPresentation(id, track.timeBase); clocks.set(id, clocks.get(id) + track.timeBase); }
316
+ state.at += 16;
303
317
  continue;
304
318
  }
305
- if (offsetBase === null) {
319
+ if (state.offsetBase === null) {
306
320
  for (const base of [movi.start, 0, movi.start + 4]) {
307
321
  const address = base + offset;
308
322
  if (address < movi.start + 4 || address + 8 + length > movi.end) continue;
309
323
  const probe = await this.readRange(address, address + 7);
310
- if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { offsetBase = base; break; }
324
+ if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { state.offsetBase = base; break; }
311
325
  }
312
- if (offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
326
+ if (state.offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
313
327
  }
314
- const address = offsetBase + offset;
328
+ const address = state.offsetBase + offset;
315
329
  if (address < movi.start + 4 || address + 8 + length > movi.end) throw new Error("AVI indexed packet exceeds its media list.");
316
330
  const probe = await this.readRange(address, address + 7);
317
331
  if (probe.toString("ascii", 0, 4) !== chunkId || probe.readUInt32LE(4) !== length) throw new Error("AVI packet index disagrees with its chunk header.");
@@ -321,15 +335,21 @@ export class AviContainer extends Container {
321
335
  await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || chunkId.endsWith("db") || !!(flags & 0x10),
322
336
  ranges: [[address + 8, address + 8 + length - 1]] });
323
337
  clocks.set(id, pts + duration);
338
+ // The pending facts own this entry even if their allocation must wait.
339
+ state.at += 16;
324
340
  index.flushPending();
325
341
  }
326
342
  for (const timing of timings.values()) timing.elementary?.complete();
327
343
  index.flushPending();
328
344
  for (const id of clocks.keys()) index.complete(id);
329
345
  this.#packets = index;
346
+ this.#indexed = null;
330
347
  return index;
331
348
  } catch (error) {
332
- index.dispose();
349
+ if (!isUnavailable(error) && !(error instanceof IndexMemoryUnavailable)) {
350
+ index.dispose();
351
+ this.#indexed = null;
352
+ }
333
353
  throw error;
334
354
  }
335
355
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file How much of the machine's memory is available, read once.
2
+ * @file How much memory an allocation of this process could obtain, read once.
3
3
  *
4
4
  * It was written THREE TIMES, in three layers, with the same body each time:
5
5
  * `transport/health-collector.js` for the score a proxy publishes to the registry,
@@ -22,35 +22,264 @@
22
22
  * estimate of what an allocation could actually obtain. Where there is no
23
23
  * `/proc` — not Linux, or it is unreadable — `freemem` is the best answer there
24
24
  * is, and on those systems it is not misleading.
25
+ *
26
+ * **Why a container's limit as well.** `/proc/meminfo` describes the HOST. A
27
+ * proxy run under Docker's `--memory` or systemd's `MemoryMax=` is killed by
28
+ * the kernel at its cgroup's limit whatever the host has free: on 2026-10-07 a
29
+ * 512 MiB container was killed while this reading said 4437 MB
30
+ * (torrent-tv/meta#153). So the answer is the smaller of the host's figure and
31
+ * the room left under every memory limit on this process's cgroup path — its
32
+ * own and each ancestor's, because a slice limit binds every unit inside it.
33
+ * The proxy runs anywhere, so nothing here knows of any particular host: the
34
+ * cgroup is found from `/proc/self/cgroup`, and where nothing is readable the
35
+ * answer is the host's alone, as it always was.
36
+ *
37
+ * **The room under a limit is NOT `limit − usage`,** for the same reason the
38
+ * host's figure is not `freemem`: the cgroup is charged for the file pages its
39
+ * processes read and write, and the kernel takes those back before it kills
40
+ * anything. Measured in a 512 MiB container on the Home Assistant host: after a
41
+ * 600 MB file was written `memory.current` stood at 531 MB of 537 — 515 MB of
42
+ * it file cache, 0.12 MB anonymous — and a 450 MB allocation then succeeded
43
+ * with no OOM kill, the cache shrinking to 50 MB. `limit − usage` said 5 MB. So
44
+ * the reclaimable part is added back, and it is the part the kernel's own
45
+ * `MemAvailable` counts: the file LRU lists and reclaimable slab
46
+ * (`active_file`, `inactive_file`, `slab_reclaimable` in cgroup v2's
47
+ * `memory.stat`; v1 states the file lists hierarchically as `total_*`). The
48
+ * watermark share `MemAvailable` subtracts has no counterpart under a cgroup
49
+ * limit, which is hard, so nothing is subtracted for it.
25
50
  */
26
51
 
27
52
  import { readFileSync } from "node:fs";
28
53
  import os from "node:os";
29
54
 
30
55
  /**
31
- * What the machine could give an allocation right now.
56
+ * Where each cgroup version keeps its memory controller, and under which names.
57
+ *
58
+ * Version 1 states the file lists of the cgroup alone as `active_file` and of
59
+ * it with its descendants as `total_active_file`; its usage includes the
60
+ * descendants, so the hierarchical figures are the ones that subtract from it.
61
+ */
62
+ const HIERARCHIES = {
63
+ 1: {
64
+ mount: "/sys/fs/cgroup/memory",
65
+ limit: "memory.limit_in_bytes",
66
+ usage: "memory.usage_in_bytes",
67
+ reclaimable: ["total_active_file", "total_inactive_file"]
68
+ },
69
+ 2: {
70
+ mount: "/sys/fs/cgroup",
71
+ limit: "memory.max",
72
+ usage: "memory.current",
73
+ reclaimable: ["active_file", "inactive_file", "slab_reclaimable"]
74
+ }
75
+ };
76
+
77
+ /**
78
+ * @typedef {Object} MemoryReading
79
+ * @property {number} bytes - What an allocation could obtain right now.
80
+ * @property {"cgroup v2" | "cgroup v1" | "MemAvailable" | "freemem"} source -
81
+ * Which reading decided `bytes`: a container's limit, the kernel's own
82
+ * estimate for the host, or free memory where that estimate was unreadable.
83
+ * @property {number | null} limitBytes - The smallest memory limit in force on
84
+ * this process's cgroup path, or null where there is none. Reported whether
85
+ * or not it decided, so a log says that a limit exists.
86
+ * @property {number} totalBytes - The most this process could ever hold: the
87
+ * limit where there is one, the host's memory otherwise.
88
+ */
89
+
90
+ /**
91
+ * What an allocation of this process could obtain right now.
32
92
  *
33
- * @returns {{ bytes: number, measured: boolean }} `measured` is false where the
34
- * kernel's own estimate was not available, so a caller that prints the figure
35
- * can say which of the two it is holding.
93
+ * @param {Object} [options]
94
+ * @param {(path: string) => string} [options.readFile] - Reads a file as text,
95
+ * throwing where it is absent or unreadable; injected by the checks.
96
+ * @returns {MemoryReading}
36
97
  */
37
- export function availableMemory() {
98
+ export function availableMemory({ readFile = (path) => readFileSync(path, "utf8") } = {}) {
99
+ const hostTotal = os.totalmem();
100
+ const host = hostAvailable(readFile);
101
+ const container = containerRoom(readFile, hostTotal);
102
+ if (container !== null && container.roomBytes < host.bytes) {
103
+ return {
104
+ bytes: container.roomBytes,
105
+ source: `cgroup v${container.version}`,
106
+ limitBytes: container.limitBytes,
107
+ totalBytes: container.limitBytes
108
+ };
109
+ }
110
+ return {
111
+ bytes: host.bytes,
112
+ source: host.source,
113
+ limitBytes: container?.limitBytes ?? null,
114
+ totalBytes: container?.limitBytes ?? hostTotal
115
+ };
116
+ }
117
+
118
+ /**
119
+ * What an allocation of this process could obtain right now, as a plain number.
120
+ *
121
+ * @returns {number}
122
+ */
123
+ export function availableMemoryBytes() {
124
+ return availableMemory().bytes;
125
+ }
126
+
127
+ /**
128
+ * The host's own estimate, or its free memory where the estimate is unreadable.
129
+ *
130
+ * @param {(path: string) => string} readFile
131
+ * @returns {{ bytes: number, source: "MemAvailable" | "freemem" }}
132
+ */
133
+ function hostAvailable(readFile) {
38
134
  try {
39
- const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFileSync("/proc/meminfo", "utf8"));
135
+ const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFile("/proc/meminfo"));
40
136
  if (match) {
41
- return { bytes: Number(match[1]) * 1024, measured: true };
137
+ return { bytes: Number(match[1]) * 1024, source: "MemAvailable" };
42
138
  }
43
139
  } catch {
44
140
  // silent-ok: not Linux, or /proc is not readable.
45
141
  }
46
- return { bytes: os.freemem(), measured: false };
142
+ return { bytes: os.freemem(), source: "freemem" };
143
+ }
144
+
145
+ /**
146
+ * The room left under every memory limit on this process's cgroup path.
147
+ *
148
+ * @param {(path: string) => string} readFile
149
+ * @param {number} hostTotal - The host's memory; a limit at or above it cannot
150
+ * be reached by memory use, which is how cgroup v1 says "no limit".
151
+ * @returns {{ version: 1 | 2, roomBytes: number, limitBytes: number } | null}
152
+ * Null where no limit is in force or nothing is readable.
153
+ */
154
+ function containerRoom(readFile, hostTotal) {
155
+ let membership;
156
+ try {
157
+ membership = ownCgroup(readFile("/proc/self/cgroup"));
158
+ } catch {
159
+ return null; // silent-ok: not Linux, or /proc is not readable.
160
+ }
161
+ if (membership === null) {
162
+ return null;
163
+ }
164
+ const hierarchy = HIERARCHIES[membership.version];
165
+ let found = null;
166
+ for (const directory of levelsOf(hierarchy.mount, membership.path)) {
167
+ const level = roomAt(readFile, hierarchy, directory, hostTotal);
168
+ if (level === null) {
169
+ continue;
170
+ }
171
+ found = {
172
+ version: membership.version,
173
+ roomBytes: Math.min(found?.roomBytes ?? Infinity, level.roomBytes),
174
+ limitBytes: Math.min(found?.limitBytes ?? Infinity, level.limitBytes)
175
+ };
176
+ }
177
+ return found;
47
178
  }
48
179
 
49
180
  /**
50
- * What the machine could give an allocation right now, as a plain number.
181
+ * Which cgroup this process is in, for the memory controller.
51
182
  *
183
+ * A host in hybrid mode lists both versions; the memory controller is then on
184
+ * version 1, named in the line that lists it.
185
+ *
186
+ * @param {string} text - `/proc/self/cgroup`.
187
+ * @returns {{ version: 1 | 2, path: string } | null}
188
+ */
189
+ function ownCgroup(text) {
190
+ for (const line of text.split("\n")) {
191
+ const match = /^\d+:([^:]*):(.*)$/.exec(line);
192
+ if (match && match[1].split(",").includes("memory")) {
193
+ return { version: 1, path: match[2] };
194
+ }
195
+ }
196
+ const unified = /^0::(.*)$/m.exec(text);
197
+ return unified ? { version: 2, path: unified[1] } : null;
198
+ }
199
+
200
+ /**
201
+ * The cgroup's own directory and each ancestor's, up to the mount.
202
+ *
203
+ * The own directory may be absent: Docker on cgroup v1 names the host's path
204
+ * while mounting the container's own directory at the root. The mount is
205
+ * always on the list, so in that case it is what is read. A path with `..` is
206
+ * a cgroup outside this namespace's root, which nothing under the mount
207
+ * describes, so nothing is read for it and the host's figure stands.
208
+ *
209
+ * @param {string} mount
210
+ * @param {string} path
211
+ * @returns {string[]}
212
+ */
213
+ function levelsOf(mount, path) {
214
+ const parts = path.split("/").filter(Boolean);
215
+ if (parts.includes("..")) {
216
+ return [];
217
+ }
218
+ const levels = [];
219
+ for (let depth = parts.length; depth >= 0; depth -= 1) {
220
+ levels.push([mount, ...parts.slice(0, depth)].join("/"));
221
+ }
222
+ return levels;
223
+ }
224
+
225
+ /**
226
+ * The limit in one cgroup directory and the room left under it.
227
+ *
228
+ * @param {(path: string) => string} readFile
229
+ * @param {(typeof HIERARCHIES)[1]} hierarchy
230
+ * @param {string} directory
231
+ * @param {number} hostTotal
232
+ * @returns {{ roomBytes: number, limitBytes: number } | null} Null where this
233
+ * level states no limit — `max`, the version 1 sentinel — or is not there.
234
+ */
235
+ function roomAt(readFile, hierarchy, directory, hostTotal) {
236
+ let limitBytes;
237
+ let usageBytes;
238
+ try {
239
+ limitBytes = bytesOf(readFile(`${directory}/${hierarchy.limit}`));
240
+ usageBytes = bytesOf(readFile(`${directory}/${hierarchy.usage}`));
241
+ } catch {
242
+ return null; // silent-ok: no such level, or no memory controller on it.
243
+ }
244
+ if (!Number.isFinite(limitBytes) || limitBytes >= hostTotal || !Number.isFinite(usageBytes)) {
245
+ return null;
246
+ }
247
+ let reclaimableBytes = 0;
248
+ try {
249
+ reclaimableBytes = statSum(readFile(`${directory}/memory.stat`), hierarchy.reclaimable);
250
+ } catch {
251
+ // silent-ok: counting nothing as reclaimable understates the room, which
252
+ // errs away from the kill this reading exists to avoid.
253
+ }
254
+ return { limitBytes, roomBytes: Math.max(0, limitBytes - usageBytes + reclaimableBytes) };
255
+ }
256
+
257
+ /**
258
+ * A byte count written by the kernel, or NaN for anything else (`max`).
259
+ *
260
+ * @param {string} text
52
261
  * @returns {number}
53
262
  */
54
- export function availableMemoryBytes() {
55
- return availableMemory().bytes;
263
+ function bytesOf(text) {
264
+ const trimmed = text.trim();
265
+ return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
266
+ }
267
+
268
+ /**
269
+ * The sum of the named fields of a `memory.stat`.
270
+ *
271
+ * @param {string} text
272
+ * @param {string[]} names
273
+ * @returns {number}
274
+ */
275
+ function statSum(text, names) {
276
+ let sum = 0;
277
+ for (const line of text.split("\n")) {
278
+ const [name, value = ""] = line.split(" ");
279
+ if (names.includes(name)) {
280
+ const bytes = bytesOf(value);
281
+ sum += Number.isFinite(bytes) ? bytes : 0;
282
+ }
283
+ }
284
+ return sum;
56
285
  }
@@ -168,13 +168,6 @@ export async function readMappingSummary() {
168
168
  return null;
169
169
  }
170
170
 
171
- /**
172
- * Available memory, from the one place that reads it.
173
- *
174
- * @returns {{ bytes: number, measured: boolean }}
175
- */
176
- export { availableMemory };
177
-
178
171
  /**
179
172
  * Anonymous memory this process holds, from the kernel's own rollup.
180
173
  *
@@ -254,8 +247,8 @@ function megabytes(bytes) {
254
247
  * @param {"process" | "thread"} [reading.scope]
255
248
  * @param {string} [reading.label] - Which thread the isolate figures are of.
256
249
  * @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
257
- * @param {number} [reading.availableBytes]
258
- * @param {boolean} [reading.availableMeasured]
250
+ * @param {import("./machine-memory.js").MemoryReading} [reading.available] -
251
+ * What an allocation could obtain, and which reading decided it.
259
252
  * @param {number | null} [reading.anonymousBytes]
260
253
  * @param {ReturnType<typeof summariseMappings> | null} [reading.mappings]
261
254
  * @param {number | null} [reading.diskFreeBytes]
@@ -272,8 +265,7 @@ export function describeMemory({
272
265
  scope = "process",
273
266
  label = "",
274
267
  process: usage,
275
- availableBytes,
276
- availableMeasured,
268
+ available,
277
269
  anonymousBytes = null,
278
270
  mappings = null,
279
271
  diskFreeBytes = null,
@@ -312,13 +304,32 @@ export function describeMemory({
312
304
  `memory: rss=${megabytes(usage.rss)} ${isolate}` +
313
305
  `${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}${shape}; ` +
314
306
  `${storesPart}; ` +
315
- `machine has ${megabytes(availableBytes ?? 0)} available` +
316
- `${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
307
+ describeAvailable(available) +
317
308
  `${diskFreeBytes === null ? "" : `, ${megabytes(diskFreeBytes)} free on disk`}` +
318
309
  tail
319
310
  );
320
311
  }
321
312
 
313
+ /**
314
+ * What an allocation could obtain, with the reading that decided it and the
315
+ * container's limit, so a log says whether a limit is in force.
316
+ *
317
+ * @param {import("./machine-memory.js").MemoryReading | undefined} available
318
+ * @returns {string}
319
+ */
320
+ function describeAvailable(available) {
321
+ if (!available) {
322
+ return "available memory not read";
323
+ }
324
+ const decided = available.source === "freemem"
325
+ ? "estimated from free memory — /proc/meminfo could not be read"
326
+ : `${available.source} decides`;
327
+ const limit = available.limitBytes === null
328
+ ? "no container limit"
329
+ : `container limit ${megabytes(available.limitBytes)}`;
330
+ return `machine has ${megabytes(available.bytes)} available (${decided}; ${limit})`;
331
+ }
332
+
322
333
  /**
323
334
  * The figures whose movement earns a line, each under its own name.
324
335
  *
@@ -540,14 +551,13 @@ export function startMemoryReport({
540
551
  // mapping and a busy process has thousands; the rollup and
541
552
  // `/proc/meminfo` are single lines but still walk page tables, and
542
553
  // the reading now happens once a second rather than once a minute.
543
- const { bytes, measured } = await availableMemory();
554
+ const available = availableMemory();
544
555
  anonymousBytes = await readAnonymousMemory();
545
556
  log(describeMemory({
546
557
  scope,
547
558
  label,
548
559
  process: processMemory,
549
- availableBytes: bytes,
550
- availableMeasured: measured,
560
+ available,
551
561
  anonymousBytes,
552
562
  mappings: await readMappingSummary(),
553
563
  diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
@@ -16,16 +16,19 @@ import os from "node:os";
16
16
  * Suitable as input to `Math.max(0, 1 - Math.min(1, cpuLoad))` for a
17
17
  * normalised "CPU availability" score.
18
18
  *
19
- * `memFree` — fraction of total system RAM that could still be given out
20
- * (0–1). The reading is the storage component's (`storage/machine-memory.js`
21
- * says why that is not the same as free memory), handed in.
19
+ * `memFree` — fraction of the memory this process could ever hold that could
20
+ * still be given out (0–1): of a container's limit where one is in force, of
21
+ * the host's RAM otherwise — divided by the host's RAM, a 512 MiB container
22
+ * with half its limit free read as nearly full. The reading is the storage
23
+ * component's (`storage/machine-memory.js` says why that is not the same as
24
+ * free memory), handed in.
22
25
  *
23
26
  * `uptime` — process uptime in whole seconds (useful for preferring
24
27
  * already-warmed proxies over freshly started ones).
25
28
  *
26
29
  * @typedef {Object} HealthMetrics
27
30
  * @property {number} cpuLoad - 1-min load avg / cpu-count. 0 = idle, 1 = saturated, >1 = overloaded.
28
- * @property {number} memFree - Memory an allocation could obtain, as a fraction of total RAM (0–1).
31
+ * @property {number} memFree - Memory an allocation could obtain, as a fraction of the most this process could hold (0–1).
29
32
  * @property {number} uptime - Process uptime in seconds.
30
33
  */
31
34
 
@@ -36,14 +39,16 @@ import os from "node:os";
36
39
  * diff noise when serialising to JSON across the tunnel.
37
40
  *
38
41
  * @param {object} params
39
- * @param {() => number} params.availableMemoryBytes - What the machine could
40
- * give an allocation right now; the storage component's reading.
42
+ * @param {() => { bytes: number, totalBytes: number }} params.availableMemory -
43
+ * What an allocation could obtain right now and the most this process could
44
+ * ever hold; the storage component's reading.
41
45
  * @returns {HealthMetrics}
42
46
  */
43
- export function collectHealthMetrics({ availableMemoryBytes }) {
47
+ export function collectHealthMetrics({ availableMemory }) {
44
48
  const cpuCount = os.cpus().length || 1;
45
49
  const cpuLoad = os.loadavg()[0] / cpuCount;
46
- const memFree = availableMemoryBytes() / os.totalmem();
50
+ const { bytes, totalBytes } = availableMemory();
51
+ const memFree = bytes / totalBytes;
47
52
 
48
53
  return {
49
54
  cpuLoad: Math.round(cpuLoad * 1000) / 1000,