@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.
- package/dist/services/chunkEncoder.d.ts +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +50 -13
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +4 -3
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts +9 -2
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +52 -15
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/systemMemory.d.ts +11 -8
- package/dist/services/systemMemory.d.ts.map +1 -1
- package/dist/services/systemMemory.js +110 -9
- package/dist/services/systemMemory.js.map +1 -1
- package/package.json +2 -2
- package/src/services/chunkEncoder.test.ts +330 -1
- package/src/services/chunkEncoder.ts +70 -10
- package/src/services/parallelCoordinator.ts +4 -3
- package/src/services/streamingEncoder.test.ts +156 -10
- package/src/services/streamingEncoder.ts +70 -16
- package/src/services/systemMemory.test.ts +303 -2
- package/src/services/systemMemory.ts +137 -9
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Auto-detects optimal worker count based on CPU/memory.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { cpus, freemem
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
463
|
-
// Reset inactivity timer ONLY on `accepted === true`. `true`
|
|
464
|
-
// write went through to the kernel pipe without buffering in
|
|
465
|
-
// proof FFmpeg is actually consuming. `false` means Node's writable
|
|
466
|
-
// stream had to buffer (FFmpeg hasn't drained the pipe yet); we
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
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 {
|
|
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
|
});
|