/** * Kernel-enforced memory ceiling for heavy tools CLEO spawns (T12116). * * ## Why the environment variables were never enough * * {@link ./heavy-tool-env.ts | heavyToolEnv} bounds a heavy tool by exporting * `NODE_OPTIONS`, `VITEST_MAX_WORKERS` and `npm_config_workspace_concurrency`. * Every one of those is **advisory and runner-specific**: * * - `VITEST_MAX_WORKERS` binds vitest. It does nothing for jest, mocha, * `cargo test`, `pytest`, `go test`, or a hand-rolled `make test` — and * CLEO is a general task runner whose consumers are not all Node projects. * - `--max-old-space-size` caps the V8 **old space**, not RSS. Buffers, typed * arrays, native allocations and every non-Node child are outside it. * - A tool is free to ignore all of them. * * So the ceiling only ever bound the case we happened to think of. The failure * this is written against was not an OOM kill at all: `app.slice` reached its * `MemoryHigh`, the kernel throttled and thrashed swap, and the desktop locked * up **without logging anything** — which is why repeated OOM hunts found * nothing. A throttle-and-thrash freeze is silent. * * ## What this does instead * * Spawn the tool inside its own transient systemd scope with a hard * `MemoryMax` and, critically, `MemorySwapMax=0`. Two properties follow, and * neither depends on the tool's language or cooperation: * * 1. **The bound is the kernel's**, applied to the whole process tree. A * `pnpm -r` fan-out into fifteen packages is still one cgroup. * 2. **Breaching it kills the tool, not the host.** With swap denied, the * cgroup cannot thrash its way into freezing the desktop; it OOMs inside * its own boundary and CLEO reports a failed run. A failed test run is a * result. A frozen workstation is not. * * Verified on Fedora 44 / systemd 258 before this module was written: a scope * created with `MemoryMax=512M` reports `memory.max=536870912` and * `memory.swap.max=0` from inside, and a Node process touching 2 GiB is killed * at ~450 MiB RSS rather than allocating. * * Where systemd is unavailable (macOS, Windows, a container without a user * manager) this degrades to a no-op and the env-var overlay remains the only * bound — the same protection as before, never less. * * @module * @task T12116 */ import type { CanonicalTool } from './tool-resolver.js'; /** Fraction of host RAM one heavy tool invocation may occupy. */ export declare const HEAVY_TOOL_RAM_FRACTION = 0.25; /** Never grant a single invocation more than this, however large the host. */ export declare const HEAVY_TOOL_MEMORY_CEILING_MB = 8192; /** Never grant less than this — a real test suite must be able to run. */ export declare const HEAVY_TOOL_MEMORY_FLOOR_MB = 2048; /** Env var an operator can set to override the derived ceiling. */ export declare const MEMORY_MAX_ENV = "CLEO_TOOL_MEMORY_MAX_MB"; /** Env var to disable cgroup confinement entirely. */ export declare const DISABLE_ENV = "CLEO_NO_TOOL_CGROUP"; /** A command ready to spawn, plus whether a kernel bound was applied. */ export interface LimitedCommand { /** Executable to spawn. */ readonly cmd: string; /** Arguments for {@link cmd}. */ readonly args: readonly string[]; /** `true` when the command runs inside a memory-bounded cgroup. */ readonly confined: boolean; /** Ceiling applied, in MiB; `null` when unconfined. */ readonly memoryMaxMb: number | null; /** * The explicit transient-unit name this command runs under, or `null` when * unconfined. * * Reported so a caller can stop or enumerate the scope. `suite-reaper.ts` * already reaps via `systemctl --user stop ` and could never * target a heavy-tool scope while the name was systemd's auto-generated one. * * @task T12221 (gh#1396) */ readonly unitName: string | null; } /** * Memory ceiling for one heavy-tool invocation, in MiB. * * Derived from host RAM so a 16 GiB laptop is not handed a 45 GiB allowance, * then clamped. The result is per-invocation; total exposure is this times the * tool semaphore's concurrency, which is itself RAM-derived (T12091). * * @param totalRamGib - host RAM in GiB; injectable for deterministic tests. * @param env - environment to read the operator override from. * @returns the ceiling in MiB. * * @example * ```ts * resolveMemoryMaxMb(62); // → 8192 (clamped from 15872) * resolveMemoryMaxMb(8); // → 2048 (floor) * ``` */ export declare function resolveMemoryMaxMb(totalRamGib?: number, env?: NodeJS.ProcessEnv): number; /** * Whether transient memory-bounded scopes can be created on this host. * * Probes once per process, because the answer cannot change mid-run and the * probe costs a subprocess. Requires both `systemd-run` and a **running user * manager** — the binary alone is not enough inside a container. * * @param env - environment to read {@link DISABLE_ENV} from. * @returns `true` when {@link withMemoryLimit} can confine a spawn. */ export declare function cgroupConfinementAvailable(env?: NodeJS.ProcessEnv): boolean; /** Reset the cached probe. Test-only. @internal */ export declare function _resetConfinementCache(): void; /** * Build the transient-unit name a confined heavy tool runs under. * * `cleo-tool---.scope`, e.g. * `cleo-tool-test-3f9a21c4-b7e01d55.scope`. Each component earns its place: * * | Component | Defends against | * |---------------|---------------------------------------------------| * | `cleo-tool-` | *(legibility)* `systemctl --user list-units 'cleo-tool-*'` enumerates exactly CLEO's heavy-tool scopes, distinct from `cleo-agent-*`. | * | `` | *(legibility)* an operator sees WHICH tool is stuck without opening anything. | * | `` | two worktrees of one repo, and two projects on one host — both have different execution roots, so each gets its own namespace and either can be reaped without touching the other. | * | `` | two concurrent verifies of the same tool in the same tree, which differ in nothing else; and PID reuse, by not using the PID. | * * **Deliberately not the PID.** The PID is the component that makes systemd's * auto-generated `run-p-i` collide, and it defends nothing randomness * does not. Including it out of familiarity would import the failure mode this * name exists to remove. * * `rootHash8` is taken off the execution root — the same identity the cache key * already uses (T12112 / gh#1220) — so the scope and the cache agree on what * "the tree under test" means rather than each deciding separately. * * At ~38 characters of `[a-z0-9-]` this is far inside systemd's 256-byte unit * name limit and uses only characters valid in a unit name. * * @param canonical - the heavy tool being run. * @param executionRoot - absolute path of the tree the tool runs in. * @returns a unit name ending in `.scope`. * * @task T12221 (gh#1396) */ export declare function buildToolScopeUnitName(canonical: CanonicalTool, executionRoot: string): string; /** * Is `cmd` a `systemd-run` invocation? * * Compares the basename so an absolute path (`/usr/bin/systemd-run`) is * recognised as readily as the bare name a `testing.command` usually carries. * * @param cmd - the executable a resolved tool command starts with. * @returns `true` when the command is itself systemd-run. * * @task T12221 (gh#1396) */ export declare function isSystemdRunCommand(cmd: string): boolean; /** * Wrap a heavy tool command so the kernel bounds its whole process tree. * * Applied only to `test` and `build` — the canonicals that fork. Confining * `lint` or `typecheck` would add a subprocess to bound a single cheap process. * * `--scope` (not `--unit`) keeps the tool a child of this process, so CLEO's * existing stdio piping, timeout and exit-code handling are unchanged; * `--collect` reaps the transient unit so repeated verifies do not accumulate * scope units. * * @param canonical - the tool about to run. * @param cmd - executable. * @param args - arguments. * @param opts - overrides for deterministic tests. * @returns the command to spawn, confined where possible. * * @example * ```ts * const c = withMemoryLimit('test', 'pnpm', ['run', 'test']); * // c.cmd === 'systemd-run' * // c.args === ['--user','--scope','--quiet','--collect', * // '-p','MemoryMax=8192M','-p','MemorySwapMax=0', * // '--','pnpm','run','test'] * ``` * * @task T12116 */ export declare function withMemoryLimit(canonical: CanonicalTool, cmd: string, args: readonly string[], opts?: { totalRamGib?: number; env?: NodeJS.ProcessEnv; available?: boolean; /** * Tree the tool runs in, used for the unit name's `rootHash8`. Defaults to * `process.cwd()` so existing callers keep working; the tool-cache passes * the execution root it already resolved. */ executionRoot?: string; }): LimitedCommand; /** * One sentence describing the memory bound a given tool runs under, for use in * an error message. Empty string when the tool is not confined. * * Derived by asking {@link withMemoryLimit} rather than re-testing * `isHeavyTool` and re-reading the ceiling. That matters here for the same * reason the comment inside `withMemoryLimit` gives: four independent literals * agreeing by coincidence is how a fifth heavy tool gets a long deadline and * no memory bound. A message that confidently names a ceiling the tool does * not actually run under would be worse than no message. * * Deliberately does NOT assert that a kill was an OOM kill. `SIGKILL` inside a * bounded scope is *consistent* with the cgroup being OOM-killed and is also * what an operator's own `kill -9` produces. The caller reports the signal it * measured; this reports the ceiling as configured; the two facts sit next to * each other and the reader draws the conclusion. Inferring OOM from SIGKILL * alone would be the same defect this text exists to fix — a true observation * stated as a cause it does not establish. * * @param canonical - the tool that was run. * @param env - environment, for tests. * @returns a leading-space sentence, or `''` when unconfined. * * @task T12116 */ export declare function describeMemoryLimit(canonical: CanonicalTool, env?: NodeJS.ProcessEnv): string; /** * Decide whether a non-zero exit came from CLEO's own confinement wrapper * failing to start, rather than from the wrapped tool running and failing. * * ## Why this cannot be done with the exit code * * `systemd-run --scope` is transparent on success: it exits with the WRAPPED * command's status. Measured on systemd 259 — a scope that starts and runs * `sh -c 'exit 7'` makes `systemd-run` exit `7`. When it cannot create the * unit it exits `1` and the wrapped command never runs. * * `1` is also the exit code of a test suite with a failing test. So the two * outcomes CLEO most needs to tell apart — "the harness never started" and * "the suite ran and was red" — are identical in the exit code, and differ * only in stderr. That is the whole of gh#1397: not a missing error code * ({@link validateTool} has had `E_EVIDENCE_TOOL_UNAVAILABLE` all along) but a * missing route into it. * * ## Scope of the claim * * Returns the matched diagnostic line so the caller can quote the real reason * rather than paraphrase it. * * Gated on whether the spawned command was a `systemd-run` invocation AT ALL — * NOT on whether CLEO added it. Those two were the same question until * double-wrap detection landed; now a project that pins its own * `systemd-run` test command is spawned unconfined by CLEO, and a failure of * ITS wrapper still means the suite never ran. Keying on * {@link LimitedCommand.confined} alone would have reported exactly the * gh#1396 project's harness failures as red suites — the defect this function * exists to remove, reintroduced by the fix for its sibling. * * This is a stderr match, so it is not airtight: a project whose own test * output contained one of these strings verbatim AND exited non-zero would be * misreported. That is judged acceptable against the alternative, which is the * status quo of reporting every harness failure as a red suite. The markers * are `systemd-run`'s, the check is gated on CLEO having wrapped the call, and * the returned message is quoted rather than asserted. * * @param stderr - captured stderr from the spawn. * @param viaSystemdRun - whether the spawned command was a systemd-run invocation, * whether CLEO added it or the project pinned it. * @returns the matched diagnostic line, or `null` when the wrapper started fine. * * @example * ```ts * confinementStartupFailure( * 'Failed to start transient scope unit: Unit run-p1-i2.scope was already loaded.', * true, * ); * // => 'Failed to start transient scope unit: Unit run-p1-i2.scope was already loaded.' * confinementStartupFailure('2 tests failed', true); // => null * confinementStartupFailure('Failed to connect to bus', false); // => null (not systemd-run) * ``` * * @task T12116 (gh#1397) */ export declare function confinementStartupFailure(stderr: string, viaSystemdRun: boolean): string | null; /** * Whether the kernel OOM-killed a process in a transient tool scope (T13011), * from the journal systemd keeps for it. The scope is collected when it ends * (`--collect`), so its `Result` and `memory.events` are gone by then; the * journal lines remain. `pnpm -r` 12+ reports a SIGKILLed child as plain * exit 1, so without this a cgroup OOM kill reads as a failing test. * * Best effort: `false` when the journal cannot be read (no `journalctl`, no * user journal, a timeout). Linux with a user systemd manager only; where no * scope confines the run (macOS, Windows) nothing is asked. * * @param unitName - {@link LimitedCommand.unitName} of the run. * @param readJournal - returns the unit's journal text (tests inject one). * @defaultValue `journalctl --user-unit= -o cat --no-pager -q` * @returns `true` when the journal reports an OOM kill in that unit. * * @example * ```ts * scopeOomKilled('cleo-tool-test-ab12cd34-ef56ab78.scope'); // true after a cgroup OOM kill * ``` * * @task T13011 */ export declare function scopeOomKilled(unitName: string, readJournal?: (unit: string) => string): boolean; //# sourceMappingURL=heavy-tool-limit.d.ts.map