@hyperframes/engine 0.6.94 → 0.6.96

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.
@@ -5,7 +5,7 @@
5
5
  * Auto-detects optimal worker count based on CPU/memory.
6
6
  */
7
7
 
8
- import { cpus, freemem, totalmem } from "os";
8
+ import { cpus, freemem } from "os";
9
9
  import { existsSync, mkdirSync, readdirSync } from "fs";
10
10
  import { copyFile, rename } from "fs/promises";
11
11
  import { join } from "path";
@@ -26,6 +26,7 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
26
26
  import { assertSwiftShader } from "../utils/assertSwiftShader.js";
27
27
  import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
28
28
  import { resolveHeadlessShellPath } from "./browserManager.js";
29
+ import { getSystemTotalMb } from "./systemMemory.js";
29
30
 
30
31
  export interface WorkerTask {
31
32
  workerId: number;
@@ -153,7 +154,7 @@ export function calculateOptimalWorkers(
153
154
  // Use total memory instead of free memory — macOS reports misleadingly low
154
155
  // freemem() because it aggressively caches files in "inactive" memory that
155
156
  // is immediately reclaimable.
156
- const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
157
+ const totalMemoryMB = getSystemTotalMb();
157
158
  const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
158
159
 
159
160
  const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
@@ -429,7 +430,7 @@ export function getSystemResources(): {
429
430
  } {
430
431
  return {
431
432
  cpuCores: cpus().length,
432
- totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
433
+ totalMemoryMB: getSystemTotalMb(),
433
434
  freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
434
435
  recommendedWorkers: calculateOptimalWorkers(1000),
435
436
  };
@@ -418,6 +418,20 @@ const baseOptions: StreamingEncoderOptions = {
418
418
  useGpu: false,
419
419
  };
420
420
 
421
+ async function resolveWithin<T>(promise: Promise<T>, ms = 100): Promise<T | "timeout"> {
422
+ let timeout: ReturnType<typeof setTimeout> | undefined;
423
+ try {
424
+ return await Promise.race([
425
+ promise,
426
+ new Promise<"timeout">((resolve) => {
427
+ timeout = setTimeout(() => resolve("timeout"), ms);
428
+ }),
429
+ ]);
430
+ } finally {
431
+ if (timeout) clearTimeout(timeout);
432
+ }
433
+ }
434
+
421
435
  describe("spawnStreamingEncoder lifecycle and cleanup", () => {
422
436
  afterEach(() => {
423
437
  vi.resetModules();
@@ -556,7 +570,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
556
570
  const dir = mkdtempSync(join(tmpdir(), "se-writefail-"));
557
571
  const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
558
572
 
559
- expect(encoder.writeFrame(Buffer.from([0]))).toBe(true);
573
+ expect(await encoder.writeFrame(Buffer.from([0]))).toBe(true);
560
574
 
561
575
  const proc = calls[0]!.proc;
562
576
  await new Promise<void>((resolve) => {
@@ -566,7 +580,136 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
566
580
  });
567
581
  });
568
582
 
569
- expect(encoder.writeFrame(Buffer.from([0]))).toBe(false);
583
+ expect(await encoder.writeFrame(Buffer.from([0]))).toBe(false);
584
+ });
585
+
586
+ it("writeFrame waits for stdin drain when FFmpeg applies back-pressure", async () => {
587
+ const { spawn, calls } = createSpawnSpy();
588
+ vi.resetModules();
589
+ vi.doMock("child_process", () => ({ spawn }));
590
+
591
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
592
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-"));
593
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
594
+
595
+ const proc = calls[0]!.proc;
596
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
597
+
598
+ const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
599
+ expect(writeResult).toBeInstanceOf(Promise);
600
+
601
+ const writePromise = writeResult as Promise<boolean>;
602
+ let settled = false;
603
+ void writePromise.then(() => {
604
+ settled = true;
605
+ });
606
+
607
+ await Promise.resolve();
608
+ expect(settled).toBe(false);
609
+ expect(proc.stdin.listenerCount("drain")).toBe(1);
610
+
611
+ proc.stdin.emit("drain");
612
+
613
+ await expect(writePromise).resolves.toBe(true);
614
+ expect(settled).toBe(true);
615
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
616
+
617
+ process.nextTick(() => proc.emit("close", 0));
618
+ await encoder.close();
619
+ });
620
+
621
+ it("does not accumulate process close listeners across repeated back-pressured writes", async () => {
622
+ const { spawn, calls } = createSpawnSpy();
623
+ vi.resetModules();
624
+ vi.doMock("child_process", () => ({ spawn }));
625
+
626
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
627
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-listeners-"));
628
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
629
+
630
+ const proc = calls[0]!.proc;
631
+ const baselineCloseListeners = proc.listenerCount("close");
632
+ const baselineDrainListeners = proc.stdin.listenerCount("drain");
633
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
634
+
635
+ for (let i = 0; i < 12; i++) {
636
+ const writePromise = encoder.writeFrame(Buffer.from([i]));
637
+
638
+ await Promise.resolve();
639
+ expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners + 1);
640
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners + 1);
641
+
642
+ proc.stdin.emit("drain");
643
+
644
+ await expect(writePromise).resolves.toBe(true);
645
+ expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners);
646
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
647
+ }
648
+
649
+ process.nextTick(() => proc.emit("close", 0));
650
+ await encoder.close();
651
+ });
652
+
653
+ it("writeFrame resolves false instead of hanging when FFmpeg exits before drain", async () => {
654
+ const { spawn, calls } = createSpawnSpy();
655
+ vi.resetModules();
656
+ vi.doMock("child_process", () => ({ spawn }));
657
+
658
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
659
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-exit-"));
660
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
661
+
662
+ const proc = calls[0]!.proc;
663
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
664
+
665
+ const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
666
+ expect(writeResult).toBeInstanceOf(Promise);
667
+
668
+ const writePromise = writeResult as Promise<boolean>;
669
+ let settled = false;
670
+ void writePromise.then(() => {
671
+ settled = true;
672
+ });
673
+
674
+ await Promise.resolve();
675
+ expect(settled).toBe(false);
676
+ expect(proc.stdin.listenerCount("drain")).toBe(1);
677
+
678
+ proc.emit("close", 1);
679
+
680
+ await expect(writePromise).resolves.toBe(false);
681
+ expect(settled).toBe(true);
682
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
683
+
684
+ const result = await encoder.close();
685
+ expect(result.success).toBe(false);
686
+ });
687
+
688
+ it("writeFrame resolves false when close fires after write returns false before await attaches listeners", async () => {
689
+ const { spawn, calls } = createSpawnSpy();
690
+ vi.resetModules();
691
+ vi.doMock("child_process", () => ({ spawn }));
692
+
693
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
694
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-already-closed-"));
695
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
696
+
697
+ const proc = calls[0]!.proc;
698
+ const baselineCloseListeners = proc.listenerCount("close");
699
+ proc.stdin.write = (_chunk: Buffer): boolean => {
700
+ proc.emit("close", 1);
701
+ return false;
702
+ };
703
+
704
+ const writePromise = encoder.writeFrame(Buffer.from([1]));
705
+
706
+ await expect(resolveWithin(writePromise)).resolves.toBe(false);
707
+ expect(encoder.getExitStatus()).toBe("error");
708
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
709
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
710
+
711
+ const result = await encoder.close();
712
+ expect(result.success).toBe(false);
570
713
  });
571
714
 
572
715
  it("close() removes the abort listener so a post-close abort does not re-kill ffmpeg", async () => {
@@ -613,7 +756,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
613
756
  // progressing" capture the encoder must still be alive. The old total-
614
757
  // render timeout would have fired SIGTERM at ~1000ms.
615
758
  for (let i = 0; i < 9; i++) {
616
- encoder.writeFrame(Buffer.from([i]));
759
+ await encoder.writeFrame(Buffer.from([i]));
617
760
  vi.advanceTimersByTime(900);
618
761
  }
619
762
  expect(proc.kill).not.toHaveBeenCalled();
@@ -647,14 +790,17 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
647
790
  const proc = calls[0]!.proc;
648
791
  proc.stdin.write = (_chunk: Buffer) => false;
649
792
 
650
- // Pump 9 frames at 900ms intervals all returning false. The reset
651
- // should NOT fire (every write was buffered, not accepted), so the
652
- // 1000ms timer (last reset on spawn) elapses near the start.
653
- for (let i = 0; i < 9; i++) {
654
- encoder.writeFrame(Buffer.from([i]));
655
- vi.advanceTimersByTime(900);
656
- }
793
+ // A buffered write should remain pending and must NOT reset the timer.
794
+ // The 1000ms timer (last reset on spawn) therefore elapses while the
795
+ // caller is correctly back-pressured on the first frame.
796
+ const writePromise = encoder.writeFrame(Buffer.from([0]));
797
+ await Promise.resolve();
798
+
799
+ vi.advanceTimersByTime(1100);
657
800
  expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
801
+
802
+ proc.emit("close", null);
803
+ await expect(writePromise).resolves.toBe(false);
658
804
  } finally {
659
805
  vi.useRealTimers();
660
806
  }
@@ -10,10 +10,11 @@
10
10
  * 1. Frame reorder buffer – ensures out-of-order parallel workers feed
11
11
  * frames to FFmpeg stdin in sequential order.
12
12
  * 2. Streaming FFmpeg encoder – spawns FFmpeg with `-f image2pipe` and
13
- * exposes a `writeFrame(buffer)` + `close()` API.
13
+ * exposes an async `writeFrame(buffer)` + `close()` API.
14
14
  */
15
15
 
16
16
  import { spawn, type ChildProcess } from "child_process";
17
+ import { once } from "events";
17
18
  import { trackChildProcess } from "../utils/processTracker.js";
18
19
  import { existsSync, mkdirSync, statSync } from "fs";
19
20
  import { dirname } from "path";
@@ -126,7 +127,14 @@ export interface StreamingEncoderResult {
126
127
  }
127
128
 
128
129
  export interface StreamingEncoder {
129
- writeFrame: (buffer: Buffer) => boolean;
130
+ /**
131
+ * Write one frame to FFmpeg stdin, awaiting `drain` when the pipe is full
132
+ * so back-pressure propagates to the caller. Resolves `false` when FFmpeg
133
+ * is already gone. Callers must serialize calls — one in-flight writeFrame
134
+ * per encoder (the frame reorder buffer provides this ordering); concurrent
135
+ * calls would interleave frame bytes on the pipe and race the drain wait.
136
+ */
137
+ writeFrame: (buffer: Buffer) => Promise<boolean>;
130
138
  close: () => Promise<StreamingEncoderResult>;
131
139
  getExitStatus: () => "running" | "success" | "error";
132
140
  }
@@ -448,9 +456,45 @@ export async function spawnStreamingEncoder(
448
456
  };
449
457
  resetTimer();
450
458
 
459
+ const waitForDrainOrExit = async (
460
+ stdin: NonNullable<ChildProcess["stdin"]>,
461
+ ): Promise<"drain" | "exit"> => {
462
+ // Back-pressure can hit once per frame. Do not race `exitPromise.then(...)`
463
+ // here: V8 retains `.then` reaction-list entries on an unsettled promise,
464
+ // so a one-hour 30fps render under steady back-pressure can accumulate
465
+ // ~108K closures + AbortControllers. Use one-shot listeners for this write
466
+ // instead, then abort them in finally. `close` is the event that flips
467
+ // `exitStatus`; re-check after listener attachment so a close emitted
468
+ // between `stdin.write(false)` and this await cannot hang forever.
469
+ const abortController = new AbortController();
470
+ try {
471
+ const drainPromise = once(stdin, "drain", { signal: abortController.signal }).then(
472
+ () => "drain" as const,
473
+ );
474
+ const closePromise = once(ffmpeg, "close", { signal: abortController.signal }).then(
475
+ () => "exit" as const,
476
+ );
477
+ const racePromise = Promise.race([drainPromise, closePromise]).catch((err: unknown) => {
478
+ if (err instanceof Error && err.name === "AbortError") {
479
+ return "exit" as const;
480
+ }
481
+ throw err;
482
+ });
483
+
484
+ if (exitStatus !== "running") {
485
+ return "exit";
486
+ }
487
+
488
+ return await racePromise;
489
+ } finally {
490
+ abortController.abort();
491
+ }
492
+ };
493
+
451
494
  const encoder: StreamingEncoder = {
452
- writeFrame: (buffer: Buffer): boolean => {
453
- if (exitStatus !== "running" || !ffmpeg.stdin || ffmpeg.stdin.destroyed) {
495
+ writeFrame: async (buffer: Buffer): Promise<boolean> => {
496
+ const stdin = ffmpeg.stdin;
497
+ if (exitStatus !== "running" || !stdin || stdin.destroyed) {
454
498
  return false;
455
499
  }
456
500
  // Copy the buffer before writing — Node streams hold a reference to the
@@ -459,18 +503,28 @@ export async function spawnStreamingEncoder(
459
503
  // so without this copy the pipe would read partially-overwritten data
460
504
  // and flicker.
461
505
  const copy = Buffer.from(buffer);
462
- const accepted = ffmpeg.stdin.write(copy);
463
- // Reset inactivity timer ONLY on `accepted === true`. `true` means the
464
- // write went through to the kernel pipe without buffering in Node —
465
- // proof FFmpeg is actually consuming. `false` means Node's writable
466
- // stream had to buffer (FFmpeg hasn't drained the pipe yet); we deliberately
467
- // don't reset on `false` so a hung FFmpeg with a still-producing Chrome
468
- // can't keep us alive forever while Node's stdin buffer grows to OOM. In
469
- // steady state with a slower-but-alive FFmpeg, writes alternate between
470
- // true and false as the buffer drains and refills; the trues are enough
471
- // to keep the heartbeat ticking.
472
- if (accepted) resetTimer();
473
- return accepted;
506
+ const accepted = stdin.write(copy);
507
+ // Reset inactivity timer immediately ONLY on `accepted === true`. `true`
508
+ // means the write went through to the kernel pipe without buffering in
509
+ // Node — proof FFmpeg is actually consuming. `false` means Node's writable
510
+ // stream had to buffer (FFmpeg hasn't drained the pipe yet); we await
511
+ // `drain` before letting callers produce the next frame, and only reset
512
+ // after drain proves consumption. We deliberately don't reset before
513
+ // drain so a hung FFmpeg with a still-producing Chrome can't keep us
514
+ // alive forever while Node's stdin buffer grows to OOM. If FFmpeg exits
515
+ // before draining, waitForDrainOrExit returns "exit", removes its
516
+ // one-shot listeners, and callers see `false` instead of hanging.
517
+ if (accepted) {
518
+ resetTimer();
519
+ return true;
520
+ }
521
+
522
+ const drainResult = await waitForDrainOrExit(stdin);
523
+ if (drainResult !== "drain" || exitStatus !== "running") {
524
+ return false;
525
+ }
526
+ resetTimer();
527
+ return true;
474
528
  },
475
529
 
476
530
  close: async (): Promise<StreamingEncoderResult> => {
@@ -1,5 +1,95 @@
1
- import { describe, it, expect } from "vitest";
2
- import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
1
+ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2
+ import {
3
+ _resetCgroupLimitCacheForTests,
4
+ isLowMemorySystem,
5
+ LOW_MEMORY_TOTAL_MB_THRESHOLD,
6
+ parseCgroupLimitMb,
7
+ } from "./systemMemory.js";
8
+
9
+ const BYTES_PER_MIB = 1024 * 1024;
10
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
11
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
12
+
13
+ type SystemMemoryModule = typeof import("./systemMemory.js");
14
+
15
+ type MockSystemMemoryOptions = {
16
+ files?: Record<string, string>;
17
+ hostTotalMb?: number;
18
+ platform?: NodeJS.Platform;
19
+ readErrors?: Record<string, NodeJS.ErrnoException>;
20
+ onRead?: (path: string) => void;
21
+ throwOnFileRead?: boolean;
22
+ };
23
+
24
+ function stubPlatform(platform: NodeJS.Platform): () => void {
25
+ const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
26
+ Object.defineProperty(process, "platform", { value: platform });
27
+
28
+ return () => {
29
+ if (descriptor) {
30
+ Object.defineProperty(process, "platform", descriptor);
31
+ }
32
+ };
33
+ }
34
+
35
+ async function withSystemMemoryMocks(
36
+ options: MockSystemMemoryOptions,
37
+ run: (systemMemory: SystemMemoryModule) => void | Promise<void>,
38
+ ): Promise<void> {
39
+ const {
40
+ files = {},
41
+ hostTotalMb = 32768,
42
+ platform = "linux",
43
+ readErrors = {},
44
+ onRead,
45
+ throwOnFileRead = false,
46
+ } = options;
47
+ const restorePlatform = stubPlatform(platform);
48
+
49
+ vi.resetModules();
50
+ vi.doMock("os", () => ({
51
+ totalmem: () => hostTotalMb * BYTES_PER_MIB,
52
+ }));
53
+ vi.doMock("fs", () => ({
54
+ readFileSync: (path: string) => {
55
+ onRead?.(path);
56
+
57
+ if (throwOnFileRead) {
58
+ throw new Error(`/sys read should not happen: ${path}`);
59
+ }
60
+
61
+ if (path in readErrors) {
62
+ throw readErrors[path];
63
+ }
64
+
65
+ if (path in files) {
66
+ return files[path];
67
+ }
68
+
69
+ throw Object.assign(new Error("missing cgroup file"), { code: "ENOENT" });
70
+ },
71
+ }));
72
+
73
+ try {
74
+ const systemMemory = await import("./systemMemory.js");
75
+ systemMemory._resetCgroupLimitCacheForTests();
76
+ await run(systemMemory);
77
+ } finally {
78
+ vi.doUnmock("fs");
79
+ vi.doUnmock("os");
80
+ vi.resetModules();
81
+ restorePlatform();
82
+ }
83
+ }
84
+
85
+ beforeEach(() => {
86
+ _resetCgroupLimitCacheForTests();
87
+ });
88
+
89
+ afterEach(() => {
90
+ _resetCgroupLimitCacheForTests();
91
+ vi.restoreAllMocks();
92
+ });
3
93
 
4
94
  describe("isLowMemorySystem", () => {
5
95
  it("treats sub-threshold RAM as low-memory", () => {
@@ -20,4 +110,215 @@ describe("isLowMemorySystem", () => {
20
110
  expect(isLowMemorySystem(16384)).toBe(false);
21
111
  expect(isLowMemorySystem(65536)).toBe(false);
22
112
  });
113
+
114
+ it("treats a 4 GiB cgroup v2 limit on a 32 GiB host as low-memory", async () => {
115
+ await withSystemMemoryMocks(
116
+ {
117
+ files: {
118
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
119
+ },
120
+ },
121
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
122
+ expect(getSystemTotalMb()).toBe(4096);
123
+ expect(isLowMemorySystem()).toBe(true);
124
+ },
125
+ );
126
+ });
127
+ });
128
+
129
+ describe("parseCgroupLimitMb", () => {
130
+ it("parses cgroup v2 numeric limits", () => {
131
+ expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, null)).toBe(4096);
132
+ });
133
+
134
+ it('ignores cgroup v2 "max" limits', () => {
135
+ expect(parseCgroupLimitMb("max", null)).toBeNull();
136
+ });
137
+
138
+ it("parses cgroup v1 numeric limits and ignores no-limit sentinels", () => {
139
+ expect(parseCgroupLimitMb(null, `${6144 * BYTES_PER_MIB}`)).toBe(6144);
140
+ expect(parseCgroupLimitMb(null, "9223372036854771712")).toBeNull();
141
+ });
142
+
143
+ it("ignores absent and malformed limits", () => {
144
+ expect(parseCgroupLimitMb(null, null)).toBeNull();
145
+
146
+ for (const content of ["", "garbage", "-1", "0"]) {
147
+ expect(parseCgroupLimitMb(content, null)).toBeNull();
148
+ expect(parseCgroupLimitMb(null, content)).toBeNull();
149
+ }
150
+ });
151
+
152
+ it("uses cgroup v2 when both v2 and v1 contents are present", () => {
153
+ expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, `${2048 * BYTES_PER_MIB}`)).toBe(4096);
154
+ });
155
+ });
156
+
157
+ describe("getSystemTotalMb", () => {
158
+ it("caches cgroup probes until the test reset hook clears the cache", async () => {
159
+ const readCalls: string[] = [];
160
+ const files = {
161
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
162
+ };
163
+
164
+ await withSystemMemoryMocks(
165
+ {
166
+ files,
167
+ onRead: (path) => readCalls.push(path),
168
+ },
169
+ ({ _resetCgroupLimitCacheForTests, getSystemTotalMb }) => {
170
+ expect(getSystemTotalMb()).toBe(4096);
171
+ expect(getSystemTotalMb()).toBe(4096);
172
+ expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH]);
173
+
174
+ files[CGROUP_V2_MEMORY_MAX_PATH] = `${2048 * BYTES_PER_MIB}`;
175
+ _resetCgroupLimitCacheForTests();
176
+
177
+ expect(getSystemTotalMb()).toBe(2048);
178
+ expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V2_MEMORY_MAX_PATH]);
179
+ },
180
+ );
181
+ });
182
+
183
+ it('uses the host total when cgroup v2 reports "max"', async () => {
184
+ await withSystemMemoryMocks(
185
+ {
186
+ files: {
187
+ [CGROUP_V2_MEMORY_MAX_PATH]: "max",
188
+ },
189
+ },
190
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
191
+ expect(getSystemTotalMb()).toBe(32768);
192
+ expect(isLowMemorySystem()).toBe(false);
193
+ },
194
+ );
195
+ });
196
+
197
+ it("honors cgroup v1 numeric limits when cgroup v2 is absent", async () => {
198
+ await withSystemMemoryMocks(
199
+ {
200
+ files: {
201
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: `${6144 * BYTES_PER_MIB}`,
202
+ },
203
+ },
204
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
205
+ expect(getSystemTotalMb()).toBe(6144);
206
+ expect(isLowMemorySystem()).toBe(true);
207
+ },
208
+ );
209
+ });
210
+
211
+ it("uses the host total when cgroup v1 reports a no-limit sentinel", async () => {
212
+ await withSystemMemoryMocks(
213
+ {
214
+ files: {
215
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: "9223372036854771712",
216
+ },
217
+ },
218
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
219
+ expect(getSystemTotalMb()).toBe(32768);
220
+ expect(isLowMemorySystem()).toBe(false);
221
+ },
222
+ );
223
+ });
224
+
225
+ it("uses the host total when cgroup files are absent", async () => {
226
+ await withSystemMemoryMocks({}, ({ getSystemTotalMb, isLowMemorySystem }) => {
227
+ expect(getSystemTotalMb()).toBe(32768);
228
+ expect(isLowMemorySystem()).toBe(false);
229
+ });
230
+ });
231
+
232
+ it("warns once and uses the host total when a cgroup file is unreadable", async () => {
233
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
234
+ await withSystemMemoryMocks(
235
+ {
236
+ readErrors: {
237
+ [CGROUP_V2_MEMORY_MAX_PATH]: Object.assign(new Error("permission denied"), {
238
+ code: "EACCES",
239
+ }),
240
+ },
241
+ },
242
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
243
+ expect(getSystemTotalMb()).toBe(32768);
244
+ expect(getSystemTotalMb()).toBe(32768);
245
+ expect(isLowMemorySystem()).toBe(false);
246
+ expect(warn).toHaveBeenCalledTimes(1);
247
+ expect(warn.mock.calls[0]?.[0]).toContain(
248
+ "[SystemMemory] Unable to read cgroup memory limit",
249
+ );
250
+ expect(warn.mock.calls[0]?.[0]).toContain("EACCES");
251
+ },
252
+ );
253
+ });
254
+
255
+ it("stays silent when cgroup files are absent", async () => {
256
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
257
+ await withSystemMemoryMocks({}, ({ getSystemTotalMb }) => {
258
+ expect(getSystemTotalMb()).toBe(32768);
259
+ expect(getSystemTotalMb()).toBe(32768);
260
+ expect(warn).not.toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ it.each(["", "garbage", "-1", "0"])(
265
+ "uses the host total for malformed cgroup v2 content %j",
266
+ async (content) => {
267
+ await withSystemMemoryMocks(
268
+ {
269
+ files: {
270
+ [CGROUP_V2_MEMORY_MAX_PATH]: content,
271
+ },
272
+ },
273
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
274
+ expect(getSystemTotalMb()).toBe(32768);
275
+ expect(isLowMemorySystem()).toBe(false);
276
+ },
277
+ );
278
+ },
279
+ );
280
+
281
+ it.each(["", "garbage", "-1", "0"])(
282
+ "uses the host total for malformed cgroup v1 content %j",
283
+ async (content) => {
284
+ await withSystemMemoryMocks(
285
+ {
286
+ files: {
287
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: content,
288
+ },
289
+ },
290
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
291
+ expect(getSystemTotalMb()).toBe(32768);
292
+ expect(isLowMemorySystem()).toBe(false);
293
+ },
294
+ );
295
+ },
296
+ );
297
+
298
+ it("uses the host total when a cgroup limit is larger than host RAM", async () => {
299
+ await withSystemMemoryMocks(
300
+ {
301
+ files: {
302
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${65536 * BYTES_PER_MIB}`,
303
+ },
304
+ },
305
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
306
+ expect(getSystemTotalMb()).toBe(32768);
307
+ expect(isLowMemorySystem()).toBe(false);
308
+ },
309
+ );
310
+ });
311
+
312
+ it("does not read cgroup files on non-Linux platforms", async () => {
313
+ await withSystemMemoryMocks(
314
+ {
315
+ platform: "darwin",
316
+ throwOnFileRead: true,
317
+ },
318
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
319
+ expect(getSystemTotalMb()).toBe(32768);
320
+ expect(isLowMemorySystem()).toBe(false);
321
+ },
322
+ );
323
+ });
23
324
  });