Skip to content
Source
Sign inSign up
Open History to see the last commit

dev-memory-limit.ts

95 lines3.6 KB

1/** Stops a dev server whose process tree outgrows a memory limit, so a leak cannot fill RAM and swap. */
2​
3import * as NodeOS from "node:os";
4​
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Schema from "effect/Schema";
9​
10const GiB = 1024 ** 3;
11​
12/** Half of RAM, at most 16 GiB. A fresh bundled-dev server uses about 7 GiB. */
13export const defaultDevMemoryLimitBytes = (totalBytes = NodeOS.totalmem()) =>
14 Math.min(16 * GiB, Math.floor(totalBytes / 2));
15​
16export class DevRunnerMemoryLimitError extends Schema.TaggedError<DevRunnerMemoryLimitError>()(
17 "DevRunnerMemoryLimitError",
18 { usedBytes: Schema.Number, limitBytes: Schema.Number },
19) {
20 override get message(): string {
21 const gib = (bytes: number) => (bytes / GiB).toFixed(1);
22 return `The dev server used ${gib(this.usedBytes)} GiB of RAM and swap, over its ${gib(this.limitBytes)} GiB limit, so it was stopped. Start it again, or set --memory-limit-gb (T3CODE_DEV_MEMORY_LIMIT_GB; 0 turns the limit off).`;
23 }
25​
26export interface ProcessSample {
27 readonly pid: number;
28 readonly ppid: number;
30​
31/** `comm` can hold spaces and parentheses, so fields are read after its last `)`. */
32export function parseProcStatParentPid(stat: string): number | undefined {
33 const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
34 return Number.isInteger(ppid) ? ppid : undefined;
36​
37export function parseProcStatusBytes(status: string): number {
38 const kib = (field: string) =>
39 Number(new RegExp(`^${field}:\\s+(\\d+) kB`, "m").exec(status)?.[1] ?? 0);
40 return (kib("VmRSS") + kib("VmSwap")) * 1024;
42​
43export function processTreePids(rootPid: number, processes: ReadonlyArray<ProcessSample>) {
44 const tree = new Set([rootPid]);
45 for (let grew = true; grew;) {
46 grew = false;
47 for (const { pid, ppid } of processes) {
48 if (tree.has(ppid) && !tree.has(pid)) {
49 tree.add(pid);
50 grew = true;
51 }
52 }
53 }
54 return tree;
56​
57const readProcessTreeBytes = Effect.fn("DevRunner.readProcessTreeBytes")(function* (
58 rootPid: number,
59) {
60 const fs = yield* FileSystem.FileSystem;
61 // Processes exit between listing and reading, so each read may fail.
62 const read = (path: string) => fs.readFileString(path).pipe(Effect.option);
63 const entries = yield* fs.readDirectory("/proc").pipe(Effect.orElseSucceed(() => []));
64 const pids = entries.filter((entry) => /^\d+$/.test(entry));
65 const processes: ProcessSample[] = [];
66 for (const pid of pids) {
67 const stat = yield* read(`/proc/${pid}/stat`);
68 const ppid = stat._tag === "Some" ? parseProcStatParentPid(stat.value) : undefined;
69 if (ppid !== undefined) processes.push({ pid: Number(pid), ppid });
70 }
71 let bytes = 0;
72 for (const pid of processTreePids(rootPid, processes)) {
73 const status = yield* read(`/proc/${pid}/status`);
74 if (status._tag === "Some") bytes += parseProcStatusBytes(status.value);
75 }
76 return bytes;
77});
78​
79/**
80 * Samples this process and its descendants every 30 seconds and fails once
81 * they hold more than `limitBytes` of RAM plus swap. Linux only; elsewhere it
82 * never completes.
83 */
84export const enforceDevMemoryLimit = Effect.fn("DevRunner.enforceDevMemoryLimit")(function* (
85 limitBytes: number,
86) {
87 if ((yield* HostProcess.Platform) !== "linux" || limitBytes <= 0) return yield* Effect.never;
88 while (true) {
89 yield* Effect.sleep("30 seconds");
90 const usedBytes = yield* readProcessTreeBytes(process.pid);
91 if (usedBytes > limitBytes) {
92 return yield* new DevRunnerMemoryLimitError({ usedBytes, limitBytes });
93 }
94 }
95});