From 66c21572defc97fe277cad49d3173bd4073d1f58 Mon Sep 17 00:00:00 2001 From: xeropresence <3128949+xeropresence@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:20:00 -0400 Subject: [PATCH] fix(bun): report the real runtime and enforce worker memory budgets on Windows (#1730) * fix(bun): report the real runtime and enforce worker memory budgets on Windows Under Bun, REA recorded Node/V8 identity it did not observe and its JavaScript helper processes ran without a memory bound, because Bun ignores the V8 heap flags REA passes. Source-map trace Evidence claimed runtime node-v8 with Bun's synthetic V8 heap limit (the decoder then rejected that limit, so trace_web_source_location always failed), analysis resource failures reported heap limits Bun does not enforce, the HAR decoder claimed a fixed 192 MiB heap, and doctor reported Bun's Node.js compatibility version as the Node version. Identify the runtime once from process.versions. Source-map reports record id bun with the Bun and Node.js compatibility versions and a null v8_heap_limit_bytes; the decoder checks the heap budget only for V8 reports and rejects a report from a runtime other than the launching one. The analysis worker reports a null heap limit under Bun, HAR limitations and failures omit heap sizes under Bun, and the doctor node check reports Bun and passes when its Node.js compatibility version is supported. On Windows under Bun, each helper's owned Job Object now carries a commit limit of its heap budget plus a measured 512 MiB Bun runtime reserve (704 MiB for the codec and HAR decoder, 1536 MiB for the default analyzer). The native addon accepts an optional page-aligned job memory limit on process_spawn, verifies it by readback, associates a private completion port before the child is created, and reports a sticky memoryLimitReached and peak job commit on each poll; inspect reports jobMemoryLimit. A requested limit with an artifact lacking the control refuses the launch, and non-Windows launchers refuse a limit. Reaching the limit is classified by the job notification rather than Bun's exit status: the analyzer fails the current source with a memory resource failure reporting worker_process_memory_limit_bytes, the codec returns a memory resource failure, and the HAR decoder returns its memory failure with process_memory_limit_bytes. Bun source-map Evidence records process_memory_limit_bytes, verified against the launcher. Node.js launches and outputs are unchanged. * fix(windows): drain memory notifications after observing exit --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/javascript-applications.md | 5 +- docs/installation.md | 12 ++ docs/javascript-artifact-reconstruction.md | 9 +- docs/testing.md | 4 +- docs/web-network-captures.md | 6 + docs/web-source-location.md | 15 +- native/windows/README.md | 25 ++- native/windows/src/authority.hpp | 6 +- native/windows/src/process.cc | 97 +++++++++- scripts/lib/windows-job-memory-limit.mjs | 180 ++++++++++++++++++ scripts/lib/windows-protocol-input.mjs | 2 +- scripts/verify-windows-native.mjs | 5 + src/application/Doctor.fixture.ts | 2 +- src/application/Doctor.test.ts | 68 +++++-- src/application/Doctor.ts | 8 +- src/application/DoctorDiagnostics.ts | 31 ++- .../WebSourceLocationService.test.ts | 46 +++++ src/application/WebSourceLocationService.ts | 21 +- src/browser/history/CaptureFailures.test.ts | 94 +++++++++ src/browser/history/CaptureFailures.ts | 30 ++- src/browser/history/CaptureRelease.ts | 11 ++ src/browser/history/HarCaptureAdapter.ts | 12 +- .../history/HistoricalCaptureDecoder.ts | 23 ++- .../history/HistoricalCaptureFormatAdapter.ts | 7 +- .../javascriptAnalysisResourceControls.ts | 3 +- src/domain/runtimeVersion.test.ts | 16 +- src/domain/runtimeVersion.ts | 22 +++ src/domain/webSourceLocation.ts | 23 ++- src/ghidra/GhidraDoctor.test.ts | 1 - .../analysis/JavaScriptAnalysisWorker.ts | 98 +++++++--- .../JavaScriptAnalysisWorkerProtocol.ts | 3 +- .../JavaScriptAnalysisWorkerServer.ts | 6 +- src/javascript/sourceMaps/SourceMapCommand.ts | 27 ++- src/javascript/sourceMaps/SourceMapDecoder.ts | 38 +++- src/javascript/sourceMaps/TraceSourceMap.ts | 26 ++- .../JavaScriptWorkerMemoryLimit.test.ts | 33 ++++ src/process/JavaScriptWorkerMemoryLimit.ts | 41 ++++ src/process/ProviderProcess.ts | 23 ++- src/process/ProviderProcessOutput.test.ts | 37 ++++ src/windows/WindowsNativeLoader.test.ts | 24 ++- src/windows/WindowsNativeLoader.ts | 2 + src/windows/WindowsOwnedProcess.test.ts | 162 +++++++++++++++- src/windows/WindowsOwnedProcess.ts | 91 +++++++-- .../tools/historicalCaptureResources.test.ts | 166 +++++++++------- tests/acceptance/webSourceLocationCli.test.ts | 4 +- .../javascriptSourceAnalysisWorker.test.ts | 132 +++++++++++++ .../javascript/sourceMapDecoder.test.ts | 115 ++++++++++- .../boundary/process/providerProcess.test.ts | 14 ++ 48 files changed, 1630 insertions(+), 196 deletions(-) create mode 100644 scripts/lib/windows-job-memory-limit.mjs create mode 100644 src/process/JavaScriptWorkerMemoryLimit.test.ts create mode 100644 src/process/JavaScriptWorkerMemoryLimit.ts diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index 25de315599..f40d3113ad 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -20,7 +20,10 @@ the complete application Evidence inline when it fits. When the connected schema advertises `max_heap_mb` and `analysis_timeout_ms`, these select the isolated source analyzer's old-space heap and per-source deadline. Defaults are 1024 MiB and 300000 ms. They do not change the MCP -process heap. Resource-limited analyses preserve completed static facts and +process heap. A Bun-hosted REA does not enforce the heap choice; on Windows it +instead limits the analyzer's committed memory to that choice plus a 512 MiB +runtime reserve. +Resource-limited analyses preserve completed static facts and report partial semantic coverage with specific unknown scopes; this does not mean the source is malformed. Inspect those limits before retrying a source. When expanded relationships exceed the parent's available memory, inspect the diff --git a/docs/installation.md b/docs/installation.md index f71c2ddb03..97f59c487d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -45,6 +45,18 @@ registration points into the runner's temporary package cache. REA supports Node.js 22.x (>=22.19), 24.x (>=24.11), and 26+. Node.js 23, 25, and prereleases are unsupported. It uses the npm already paired with that runtime and never upgrades Node.js, npm, or Homebrew. +When REA runs on Bun, the `rea doctor` `node` check reports the Bun version and +the Node.js compatibility version Bun declares, and passes when that +compatibility version is in the supported range. Bun does not enforce the V8 +heap flags REA gives its owned helper processes, so Evidence that reports those +limits on Node.js records them as unavailable under Bun. On Windows, the owned +Job Object of each JavaScript helper (source analyzer, source-map codec and HAR +decoder) instead limits its committed memory to that helper's heap budget plus +a 512 MiB reserve for Bun itself, and Evidence reports that limit. This needs +the matching bundled Windows native artifact; an artifact without the control +refuses to start the helper rather than running it unlimited. CI tests Node.js +only. + Running `npm install rea-agents` without `--global` installs the executable only in the current project's `node_modules/.bin`; it does not make `rea` available on the shell `PATH`. Use the setup command above for the guided setup diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index da5dca64bd..1c61520ea8 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -126,7 +126,14 @@ deadline per source, including startup and fact transfer. Select `max_heap_mb` and `analysis_timeout_ms` through MCP, or `--max-heap-mb` and `--analysis-timeout-ms` on the dedicated CLI command. These choices control the analyzer; the caller's `NODE_OPTIONS` still controls the CLI/MCP process heap. -V8 old-space limits do not impose a process RSS limit. +V8 old-space limits do not impose a process RSS limit. When REA runs on Bun, +the analyzer ignores the V8 heap flags; resource failures then report +`worker_heap_limit_bytes` as null instead of a requested or V8-reported limit. +On Windows, the analyzer's owned Job Object instead limits its committed +memory to `max_heap_mb` plus a 512 MiB reserve for Bun itself (1536 MiB by +default). Reaching it fails the current source with the same memory resource +failure and reports `worker_process_memory_limit_bytes`, also a coverage limit; +the next source starts a new analyzer. Bun elsewhere enforces no analyzer limit. A source that exhausts the analyzer heap does not terminate the CLI/MCP process. Completed static facts are retained, other files can still be analyzed, diff --git a/docs/testing.md b/docs/testing.md index 71c356cc7c..cfa74a1900 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -478,7 +478,9 @@ native decompiler. The namespace verifier reports missing or non-executable components before testing the fixture. REA does not build or install them. Windows native conformance runs with `npm run verify:windows-native` and does -not require Ghidra or Java. An optional independently compiled Windows fixture +not require Ghidra or Java. It includes Job Object commit-limit controls (a +limited child that reaches the limit, a control that does not, and invalid +limit rejection). An optional independently compiled Windows fixture adds in-place reparse, breakaway, environment, and token observations. `npm run verify:ghidra:windows:package` additionally packs and installs REA into an isolated prefix and runs ordinary-user CLI/MCP operations against their diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 4e91c2f125..f82b361097 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -125,6 +125,12 @@ both commands have a 30-second wall deadline and independently supervised process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout and stderr; the collector drops overflow as it arrives, reports the limit failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits. +When REA runs on Bun, the HAR decoder runs on Bun, which ignores these V8 heap +flags; its limitations and resource failures then omit the heap sizes. On +Windows, the decoder's owned Job Object instead limits its committed memory to +704 MiB, the 192 MiB budget plus a 512 MiB reserve for Bun itself. The +limitations state that limit, and reaching it returns `resource_constraint` +with `process_memory_limit_bytes`, whatever exit status Bun reports. Observed decoder memory exhaustion returns `resource_constraint`, with the selected capture, applicable limits and retained process diagnostics. HAR's diff --git a/docs/web-source-location.md b/docs/web-source-location.md index f1736ec0fc..d415a9f1b3 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -72,9 +72,18 @@ One owned Node subprocess decodes the map. Its private request directory is removed before success is returned. Only this child's inherited `NODE_OPTIONS` is removed; explicit V8 flags set a 192 MiB old-generation and 8 MiB semi-space budget, one V8 worker and a 20-second deadline. Reported actual V8 heap limits -are checked. These are heap limits, not an aggregate process RSS limit. Cleanup -may extend the deadline and an unconfirmed cleanup overrides success with -`cleanup_incomplete` and the owning operation/resources. +are checked. These are heap limits, not an aggregate process RSS limit. When +REA runs on Bun, the subprocess is Bun, which ignores these V8 flags: `runtime` +records `id: "bun"`, the Bun version, its Node.js compatibility version and a +null `v8_heap_limit_bytes`. On Windows, the codec's owned Job Object then +limits its committed memory to 704 MiB, the 192 MiB budget plus a 512 MiB +reserve for Bun itself; `process_memory_limit_bytes` records that limit, the +limitations state it, and reaching it returns `resource_constraint` with +`codec_process_memory_limit_bytes`. Elsewhere Bun has no enforced codec limit: +`process_memory_limit_bytes` is null and the limitations state that no codec +heap limit is established. Cleanup may extend the deadline and an unconfirmed +cleanup overrides success with `cleanup_incomplete` and the owning +operation/resources. Complete evidence is bounded by a 4 MiB map, 32 MiB codec reply, 262144 decoded rows/segments and 64 nested section levels. Exceeding a budget returns an explicit diff --git a/native/windows/README.md b/native/windows/README.md index 7fadb9b4e0..288fd03b19 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -51,6 +51,23 @@ without this lane reports Windows native controls as unavailable. unnamed, non-inherited Job Object. Membership and kill-on-close policy are checked before resume. Breakaway is disabled, and cleanup waits for all job members and captured output, including detached descendants. +- A launch may request a Job Object commit limit (`JOB_OBJECT_LIMIT_JOB_MEMORY`) + as a positive, page-aligned safe-integer byte count; invalid values fail + with `ERROR_INVALID_PARAMETER` before any process is created. Readback must + match the requested flags and limit exactly. A private I/O completion port is + associated with that job before the child is created; each poll drains it + without waiting and records a sticky `memoryLimitReached` when + `JOB_OBJECT_MSG_JOB_MEMORY_LIMIT` arrives, alongside the job's + `peakJobMemoryBytes`. Notifications are drained before checking for exit + and again after exit is observed, so a memory failure between those steps + is included in the first exit report. Runtimes report a reached limit + differently (Bun 1.4.2 exits 1 + after a JavaScript out-of-memory exception or 3 after a panic), so callers + classify by the notification. `inspect` reports `jobMemoryLimit` only after + creating such a job and port. An artifact without that capability refuses a + requested limit rather than launching an unlimited process. REA requests + limits only for its JavaScript helpers when running on Bun, which ignores + their V8 heap flags; Node.js launches are unchanged. - Only the suspended child's default file owner is normalized. Caller token privileges and ownership remain unchanged. Caller CPU affinity and process priority are preserved in the child. Environment names are sorted and @@ -93,7 +110,13 @@ The native lane checks DACL readback independently, source write/replacement rejection, unrelated sibling renames, reparse rejection, immutable snapshots, asynchronous cancellation, cleanup containment, descendant termination, unrelated-process preservation, normal exit, owner -close, and forced owner exit. An optional independently built +close, forced owner exit, and Job Object commit limits: invalid limit +rejection, a 128 MiB-limited child allocating external Buffer memory that the +V8 heap flags cannot bound (limit notification, peak commit at the limit, all +members settled, with the notification present in the first native exit report +and the wrapper's exit event), and a control under the same limit that +completes normally. +An optional independently built `tests/fixtures/windows/processBoundary.cc` executable adds breakaway and caller/child token observations and an in-place ancestor reparse attempt: diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp index 3c322da585..c003aae621 100644 --- a/native/windows/src/authority.hpp +++ b/native/windows/src/authority.hpp @@ -96,6 +96,10 @@ struct Runtime : Resource { }; struct Process : Resource { Handle job, process, stdoutRead, stderrRead, stdinWrite; + // Present only for a job with a commit limit; receives the job's notifications. + Handle notifications; + ULONGLONG memoryLimit = 0; + bool memoryLimitReached = false; bool inputPending = false; DWORD pid = 0; Process() : Resource(Kind::Process) {} @@ -125,7 +129,7 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector& args); std::unique_ptr spawnProcess(const std::wstring& command, const std::wstring& commandLine, const std::wstring& cwd, const std::vector& environment, - bool protocolStdin = false); + bool protocolStdin = false, ULONGLONG memoryLimit = 0); napi_value processCall(napi_env env, const std::wstring& operation, const std::vector& args); napi_value inspect(napi_env env); diff --git a/native/windows/src/process.cc b/native/windows/src/process.cc index 4b9f71963e..8ebf06fb2e 100644 --- a/native/windows/src/process.cc +++ b/native/windows/src/process.cc @@ -19,21 +19,55 @@ public: } }; -static Handle createJob() { +// A nonzero memory limit is a commit limit for the whole job; zero keeps only +// kill-on-owner-close. The policy read back must match exactly. +static Handle createJob(ULONGLONG memoryLimit = 0) { Handle job(CreateJobObjectW(nullptr, nullptr)); require(job.valid(), "Create owned Job Object failed"); + const DWORD flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | (memoryLimit != 0 ? JOB_OBJECT_LIMIT_JOB_MEMORY : 0); JOBOBJECT_EXTENDED_LIMIT_INFORMATION policy{}; - policy.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + policy.BasicLimitInformation.LimitFlags = flags; + policy.JobMemoryLimit = static_cast(memoryLimit); require(SetInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &policy, sizeof(policy)), "Apply kill-on-owner-close Job Object policy failed"); JOBOBJECT_EXTENDED_LIMIT_INFORMATION actual{}; require(QueryInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &actual, sizeof(actual), nullptr), "Read Job Object policy failed"); - require(actual.BasicLimitInformation.LimitFlags == JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + require(actual.BasicLimitInformation.LimitFlags == flags, "Job Object policy permits unexpected breakaway", L"", ERROR_ACCESS_DENIED); + require(memoryLimit == 0 || static_cast(actual.JobMemoryLimit) == memoryLimit, + "Job Object memory limit was not applied exactly", L"", ERROR_INVALID_PARAMETER); return job; } +// Associate a private completion port before any process joins the job, so +// the job's commit-limit notification cannot be missed. +static Handle createNotifications(HANDLE job) { + Handle port(CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 1)); + require(port.valid(), "Create owned Job Object notification port failed"); + JOBOBJECT_ASSOCIATE_COMPLETION_PORT association{}; + association.CompletionKey = job; + association.CompletionPort = port.get(); + require(SetInformationJobObject(job, JobObjectAssociateCompletionPortInformation, &association, sizeof(association)), + "Associate owned Job Object notification port failed"); + return port; +} + +// JavaScript numbers cross the boundary as doubles; accept only an exact +// positive safe integer that is a whole number of pages, so read-back is exact. +static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) { + const double requested = numeric(env, value); + require(requested >= 1 && requested <= 9007199254740991.0 && + static_cast(static_cast(requested)) == requested, + "Job memory limit must be a positive safe integer", L"", ERROR_INVALID_PARAMETER); + SYSTEM_INFO system{}; + GetSystemInfo(&system); + const auto limit = static_cast(requested); + require(system.dwPageSize != 0 && limit % system.dwPageSize == 0, + "Job memory limit must be a whole number of pages", L"", ERROR_INVALID_PARAMETER); + return limit; +} + static void pipe(Handle& read, Handle& write, bool parentReads = true) { SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; HANDLE rawRead, rawWrite; @@ -44,12 +78,16 @@ static void pipe(Handle& read, Handle& write, bool parentReads = true) { std::unique_ptr spawnProcess(const std::wstring& command, const std::wstring& commandLine, const std::wstring& cwd, const std::vector& environment, - bool protocolStdin) { + bool protocolStdin, ULONGLONG memoryLimit) { const auto executable = ordinaryDriveSeparators(command); require(executable.size() >= 3 && executable[1] == L':' && executable[2] == L'\\', "Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER); auto result = std::make_unique(); - result->job = createJob(); + result->job = createJob(memoryLimit); + if (memoryLimit != 0) { + result->notifications = createNotifications(result->job.get()); + result->memoryLimit = memoryLimit; + } Handle stdoutWrite, stderrWrite; pipe(result->stdoutRead, stdoutWrite); pipe(result->stderrRead, stderrWrite); SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; @@ -170,6 +208,29 @@ static DWORD activeProcesses(Process& process) { return state.ActiveProcesses; } +// Drain queued job notifications without waiting. The commit-limit message is +// posted when an allocation fails, before the process can exit. An observed +// exit must be followed by another drain before its diagnostics are published. +static void observeNotifications(Process& process) { + if (!process.notifications.valid()) return; + DWORD message = 0; + ULONG_PTR key = 0; + LPOVERLAPPED detail = nullptr; + while (GetQueuedCompletionStatus(process.notifications.get(), &message, &key, &detail, 0)) { + if (key == reinterpret_cast(process.job.get()) && message == JOB_OBJECT_MSG_JOB_MEMORY_LIMIT) + process.memoryLimitReached = true; + } + const auto error = GetLastError(); + require(detail == nullptr && error == WAIT_TIMEOUT, "Observe owned Job Object notifications failed", L"", error); +} + +static ULONGLONG peakJobMemory(Process& process) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION state{}; + require(QueryInformationJobObject(process.job.get(), JobObjectExtendedLimitInformation, &state, sizeof(state), nullptr), + "Observe owned Job Object memory failed"); + return static_cast(state.PeakJobMemoryUsed); +} + static napi_value callerToken(napi_env env) { HANDLE raw; require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &raw), "Read caller token failed"); @@ -284,16 +345,20 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v return callerToken(env); } if (operation == L"process_spawn") { - require(args.size() == 4 || args.size() == 5, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER); + require(args.size() >= 4 && args.size() <= 6, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER); bool protocolStdin = false; - if (args.size() == 5) check(napi_get_value_bool(env, args[4], &protocolStdin)); + if (args.size() >= 5) check(napi_get_value_bool(env, args[4], &protocolStdin)); + const ULONGLONG memoryLimit = args.size() == 6 ? jobMemoryLimit(env, args[5]) : 0; std::vector environment; for (const auto& entry : array(env, args[3])) environment.push_back(wide(env, entry)); - auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment, protocolStdin); + auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment, protocolStdin, + memoryLimit); auto result = object(env); set(env, result, "pid", number(env, process->pid)); set(env, result, "jobAssigned", boolean(env, true)); set(env, result, "killOnOwnerClose", boolean(env, true)); + if (process->memoryLimit != 0) + set(env, result, "jobMemoryLimitBytes", number(env, static_cast(process->memoryLimit))); set(env, result, "handle", wrap(env, std::move(process))); return result; } @@ -314,12 +379,21 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v set(env, result, "stderr", readOutput(env, process.stderrRead, stderrEnded)); set(env, result, "stdoutEnded", boolean(env, stdoutEnded)); set(env, result, "stderrEnded", boolean(env, stderrEnded)); + observeNotifications(process); const auto exited = WaitForSingleObject(process.process.get(), 0); require(exited == WAIT_OBJECT_0 || exited == WAIT_TIMEOUT, "Observe owned process exit failed"); DWORD code = 0; - if (exited == WAIT_OBJECT_0) require(GetExitCodeProcess(process.process.get(), &code), "Read owned process exit code failed"); + if (exited == WAIT_OBJECT_0) { + // The child may have reached its limit and exited after the first drain. + observeNotifications(process); + require(GetExitCodeProcess(process.process.get(), &code), "Read owned process exit code failed"); + } set(env, result, "exitCode", exited == WAIT_OBJECT_0 ? number(env, code) : null(env)); set(env, result, "activeProcesses", number(env, activeProcesses(process))); + if (process.memoryLimit != 0) { + set(env, result, "memoryLimitReached", boolean(env, process.memoryLimitReached)); + set(env, result, "peakJobMemoryBytes", number(env, static_cast(peakJobMemory(process)))); + } return result; } if (operation == L"process_alive") return number(env, activeProcesses(process)); @@ -331,6 +405,7 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v if (operation == L"process_close") { // Closing the job is the crash-safe authority; no PID enumeration is used. process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset(); process.stdinWrite.reset(); + process.notifications.reset(); process.closed = true; return null(env); } @@ -348,6 +423,9 @@ napi_value inspect(napi_env env) { Attributes attributes(1); HANDLE raw = job.get(); attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &raw, sizeof(raw)); + // Establish the commit-limit policy and its notification port on this host. + auto limited = createJob(64ULL * 1024 * 1024); + auto notifications = createNotifications(limited.get()); auto result = object(env); set(env, result, "abiVersion", number(env, 1)); set(env, result, "nodeApiVersion", number(env, 8)); @@ -357,6 +435,7 @@ napi_value inspect(napi_env env) { set(env, result, "atomicJobAssignment", boolean(env, true)); set(env, result, "killOnOwnerClose", boolean(env, true)); set(env, result, "protocolStdin", boolean(env, true)); + set(env, result, "jobMemoryLimit", boolean(env, true)); return result; } diff --git a/scripts/lib/windows-job-memory-limit.mjs b/scripts/lib/windows-job-memory-limit.mjs new file mode 100644 index 0000000000..55841c1d30 --- /dev/null +++ b/scripts/lib/windows-job-memory-limit.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const MIB = 1024 * 1024; +const LIMIT = 128 * MIB; +// Observed peaks exceed the limit by a few MiB of kernel-charged commit +// (e.g. 512 MiB -> 520 MiB, 256 MiB -> 262 MiB under Bun), never by an +// allocation the limit refused. +const PEAK_OVERSHOOT = 32 * MIB; +// External Buffer memory is committed outside the V8 heap, so V8 heap flags +// cannot bound it; only the Job Object commit limit can stop this child. +const UNBOUNDED = `const kept = []; +for (let index = 0; index < 64; index++) kept.push(Buffer.alloc(${String(16 * MIB)}, 1)); +console.log("unbounded allocation completed");`; +const BOUNDED = `const kept = Buffer.alloc(${String(MIB)}, 1); +console.log("bounded allocation completed", kept.length);`; + +/** Exercise owned Job Object commit limits through the native boundary and its production wrapper. */ +export async function verifyWindowsJobMemoryLimit(packageRoot, workspace) { + const { WindowsOwnedProcess } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsOwnedProcess.js")) + ); + const { requireWindowsNativeAuthority } = await import( + pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js")) + ); + const native = requireWindowsNativeAuthority(); + assert.equal(native.inspection.jobMemoryLimit, true); + const launch = nativeLauncher(native, workspace); + + for (const invalid of [ + 0, + -MIB, + 1.5, + LIMIT + 0.5, + 2 ** 53, + NaN, + Infinity, + 4097, + "134217728", + null, + ]) + assert.throws(() => launch(BOUNDED, false, invalid), { + code: "ERR_REA_WINDOWS_NATIVE", + win32Code: 87, + }); + + const unlimited = await settle(native, launch(BOUNDED)); + assert.equal(unlimited.exitCode, 0); + assert.equal("memoryLimitReached" in unlimited, false); + assert.equal("peakJobMemoryBytes" in unlimited, false); + + const reachedLaunch = launch(UNBOUNDED, false, LIMIT); + assert.equal(reachedLaunch.jobMemoryLimitBytes, LIMIT); + const reached = await settle(native, reachedLaunch); + assert.notEqual(reached.exitCode, 0); + assert.doesNotMatch(reached.output, /unbounded allocation completed/u); + assert.equal(reached.activeProcesses, 0); + assert.equal(reached.memoryLimitReached, true); + assert.equal(reached.memoryLimitReachedAtExit, true); + assert.ok(reached.peakJobMemoryBytes <= LIMIT + PEAK_OVERSHOOT); + assert.ok(reached.peakJobMemoryBytes > LIMIT - 64 * MIB); + + const control = await settle(native, launch(BOUNDED, false, LIMIT)); + assert.equal(control.exitCode, 0); + assert.match(control.output, /bounded allocation completed/u); + assert.equal(control.activeProcesses, 0); + assert.equal(control.memoryLimitReached, false); + assert.equal(control.memoryLimitReachedAtExit, false); + assert.ok(control.peakJobMemoryBytes < LIMIT); + + await verifyWrappedLimit(WindowsOwnedProcess, native, workspace); + + return { + limit_bytes: LIMIT, + invalid_limits_rejected: true, + unlimited_poll_shape_unchanged: true, + reached: { + exit_code: reached.exitCode, + peak_job_memory_bytes: reached.peakJobMemoryBytes, + }, + control: { + exit_code: control.exitCode, + peak_job_memory_bytes: control.peakJobMemoryBytes, + }, + wrapper_reports_limit_reached: true, + first_exit_reports_limit_reached: true, + missing_capability_refused: true, + }; +} + +function nativeLauncher(native, workspace) { + const environment = Object.entries(process.env) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${value}`); + const quote = (value) => + `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`; + return (script, ...controls) => + native.call("process_spawn", [ + process.execPath, + [process.execPath, "-e", script].map(quote).join(" "), + workspace, + environment, + ...controls, + ]); +} + +async function verifyWrappedLimit(WindowsOwnedProcess, native, workspace) { + const wrapped = new WindowsOwnedProcess( + process.execPath, + ["-e", UNBOUNDED], + workspace, + process.env, + false, + { memoryLimitBytes: LIMIT }, + ); + const closed = new Promise((resolve, reject) => { + wrapped.once("error", reject); + wrapped.once("close", (code) => resolve(code)); + }); + let memoryLimitReachedAtExit; + wrapped.once("exit", () => { + memoryLimitReachedAtExit = wrapped.memoryLimitReached; + }); + wrapped.stdout.resume(); + wrapped.stderr.resume(); + assert.notEqual(await closed, 0); + assert.equal(memoryLimitReachedAtExit, true); + assert.equal(wrapped.memoryLimitReached, true); + assert.ok(wrapped.peakJobMemoryBytes <= LIMIT + PEAK_OVERSHOOT); + + assert.throws( + () => + new WindowsOwnedProcess( + process.execPath, + ["-e", BOUNDED], + workspace, + process.env, + false, + { + memoryLimitBytes: LIMIT, + authority: { + call: native.call, + inspection: { ...native.inspection, jobMemoryLimit: undefined }, + }, + }, + ), + /does not support Job Object memory limits/u, + ); +} + +async function settle(native, child) { + let output = ""; + let firstExitState; + try { + for (let attempt = 0; attempt < 800; attempt++) { + const state = native.call("process_poll", [child.handle]); + output += state.stdout.toString(); + if (state.exitCode !== null) firstExitState ??= state; + if ( + state.exitCode !== null && + state.activeProcesses === 0 && + state.stdoutEnded && + state.stderrEnded + ) + return { + ...state, + output, + memoryLimitReachedAtExit: firstExitState.memoryLimitReached, + }; + await delay(25); + } + throw new Error( + "Memory-limited job did not settle within the fixture deadline.", + ); + } finally { + native.call("process_close", [child.handle]); + } +} diff --git a/scripts/lib/windows-protocol-input.mjs b/scripts/lib/windows-protocol-input.mjs index 1aee7462c2..a1b851ab0c 100644 --- a/scripts/lib/windows-protocol-input.mjs +++ b/scripts/lib/windows-protocol-input.mjs @@ -39,7 +39,7 @@ function acquire(Process, workspace, script) { workspace, process.env, false, - true, + { protocolStdin: true }, ); let stdout = ""; let stderr = ""; diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 07252d61a7..c63b6909f3 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -20,6 +20,7 @@ import { tmpdir } from "node:os"; import { dirname, join, parse, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { verifyWindowsProtocolInput } from "./lib/windows-protocol-input.mjs"; +import { verifyWindowsJobMemoryLimit } from "./lib/windows-job-memory-limit.mjs"; if (process.platform !== "win32") throw new Error( @@ -545,6 +546,10 @@ setInterval(()=>{},1000);\n`, packageRoot, workspace, ); + report.processes.jobMemoryLimit = await verifyWindowsJobMemoryLimit( + packageRoot, + workspace, + ); const exited = launch([ "--max-old-space-size=128", "-e", diff --git a/src/application/Doctor.fixture.ts b/src/application/Doctor.fixture.ts index 770b78fe66..de23a6e3db 100644 --- a/src/application/Doctor.fixture.ts +++ b/src/application/Doctor.fixture.ts @@ -9,7 +9,7 @@ export const createDoctorHostFixture = ( platform: "darwin", homeDirectory: "/fixture/home", architecture: "x64", - nodeVersion: "24.18.0", + runtime: { id: "node", version: "24.18.0" }, macosVersion: () => Promise.resolve("14.0"), linuxDistribution: () => Promise.resolve(undefined), validTarget: (path) => Promise.resolve(path.includes("Hopper")), diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index ebdc94f431..352724f37c 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -5,12 +5,64 @@ import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; import { runDoctor } from "./Doctor.js"; +describe("doctor runtime check", () => { + it("reports Node 25 as unsupported", async () => { + const result = await runDoctor( + undefined, + host({ runtime: { id: "node", version: "25.1.0" } }), + ); + + expect(result.healthy).toBe(false); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "node", + ok: false, + detail: "25.1.0", + details: { runtime: "node", version: "25.1.0" }, + classification: "missing_dependency", + }), + ); + }); + + it.each([ + { compatibility: "26.3.0", ok: true }, + { compatibility: "25.1.0", ok: false }, + ])( + "identifies Bun and admits it by Node.js compatibility $compatibility", + async ({ compatibility, ok }) => { + const result = await runDoctor( + undefined, + host({ + runtime: { + id: "bun", + version: "1.4.2", + nodeCompatibilityVersion: compatibility, + }, + }), + ); + + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "node", + ok, + classification: ok ? "healthy" : "missing_dependency", + details: { + runtime: "bun", + version: "1.4.2", + node_compatibility_version: compatibility, + }, + }), + ); + }, + ); +}); + describe("doctor", () => { it("returns exact recovery for every failed diagnostic", async () => { const result = await runDoctor( "/missing/app", host({ - nodeVersion: "20.0.0", + runtime: { id: "node", version: "20.0.0" }, macosVersion: () => Promise.resolve("11.7"), executable: () => Promise.resolve(false), validTarget: () => Promise.resolve(false), @@ -28,20 +80,6 @@ describe("doctor", () => { }); }); - it("reports Node 25 as unsupported", async () => { - const result = await runDoctor(undefined, host({ nodeVersion: "25.1.0" })); - - expect(result.healthy).toBe(false); - expect(result.checks).toContainEqual( - expect.objectContaining({ - name: "node", - ok: false, - detail: "25.1.0", - classification: "missing_dependency", - }), - ); - }); - it.each(["11.7", undefined])( "rejects unsupported macOS version %s", async (version) => { diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 2a781b448d..f9fc0575be 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -8,6 +8,10 @@ import { BinaryTargetError } from "../domain/configurationErrors.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + javaScriptRuntime, + type JavaScriptRuntime, +} from "../domain/runtimeVersion.js"; import { installedCaskAppdir, probeHomebrew } from "./homebrew.js"; import { linuxHopperBinarySupported, @@ -77,7 +81,7 @@ export interface DoctorProviderInspection { export interface DoctorHost { readonly platform: NodeJS.Platform; readonly architecture: NodeJS.Architecture; - readonly nodeVersion: string; + readonly runtime: JavaScriptRuntime; readonly homeDirectory: string; readonly configuredHopperPath?: string; readonly configuredIlspyCmdPath?: string; @@ -310,7 +314,7 @@ export const systemDoctorHost = ( return { platform, architecture, - nodeVersion: process.versions.node, + runtime: javaScriptRuntime(process.versions), homeDirectory, ...(environment.HOPPER_LAUNCHER_PATH === undefined ? {} diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index d8b4379ccd..354d08636e 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -91,11 +91,36 @@ export const doctorHealthy = ( ); }; -const nodeCheck = (host: DoctorHost): DoctorCheck => - check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, { +/** Bun passes when its reported Node.js compatibility version is supported. */ +const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => { + const failure = { remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, classification: "missing_dependency", - }); + } as const; + if (runtime.id === "node") + return { + ...check( + "node", + supportsNodeVersion(runtime.version), + runtime.version, + failure, + ), + details: { runtime: "node", version: runtime.version }, + }; + return { + ...check( + "node", + supportsNodeVersion(runtime.nodeCompatibilityVersion), + `Bun ${runtime.version} (Node.js ${runtime.nodeCompatibilityVersion} compatibility)`, + failure, + ), + details: { + runtime: "bun", + version: runtime.version, + node_compatibility_version: runtime.nodeCompatibilityVersion, + }, + }; +}; const hostCheck = async (host: DoctorHost): Promise => { const macosVersion = diff --git a/src/application/WebSourceLocationService.test.ts b/src/application/WebSourceLocationService.test.ts index f5db7ffe6d..81f621cd2d 100644 --- a/src/application/WebSourceLocationService.test.ts +++ b/src/application/WebSourceLocationService.test.ts @@ -93,6 +93,52 @@ it.each(["digest", "url", "point"])( expect(response.error._tag).toBe("AnalysisOutputError"); }, ); +it.each([ + { + runtime: { + id: "node-v8", + version: "v24.18.0", + v8_heap_limit_bytes: 201326592, + }, + limitation: "192 MiB old-generation heap and one V8 worker", + }, + { + runtime: { + id: "bun", + version: "1.4.2", + node_compatibility_version: "24.3.0", + v8_heap_limit_bytes: null, + process_memory_limit_bytes: 738197504, + }, + limitation: + "a Windows Job Object limited its committed memory to 738197504 bytes", + }, + { + runtime: { + id: "bun", + version: "1.4.2", + node_compatibility_version: "24.3.0", + v8_heap_limit_bytes: null, + process_memory_limit_bytes: null, + }, + limitation: "so no codec heap limit is established", + }, +] as const)( + "states the codec memory bound its runtime enforced: $limitation", + async ({ runtime, limitation }) => { + const response = await service(undefined, { + trace: async (input) => { + const decoded = await decoder.trace(input); + if (!decoded.ok) return decoded; + return ok({ ...decoded.value, runtime }); + }, + }).trace(args); + if (!response.ok) throw response.error; + expect( + response.value.limitations.filter((text) => text.includes(limitation)), + ).toHaveLength(1); + }, +); it("honors cancellation both before acquisition and across decoder completion", async () => { const first = new AbortController(); first.abort(); diff --git a/src/application/WebSourceLocationService.ts b/src/application/WebSourceLocationService.ts index c7c98d48c9..7be901311f 100644 --- a/src/application/WebSourceLocationService.ts +++ b/src/application/WebSourceLocationService.ts @@ -49,8 +49,19 @@ const LIMITATIONS = [ "Embedded source hashes identify the UTF-8 encoding of decoded sourcesContent text, not an independently retained original file. Missing or out-of-range original content positions remain explicit.", "Lookup returns all segments at the greatest generated position not greater than the selected point, including earlier lines and generated-only segments. A mapped point does not establish an executed range or UI causality.", "Regular maps and inline indexed sections are supported. External section URLs are unsupported; unknown extensions are retained in raw map text and are not interpreted.", - "Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a 20-second deadline, 192 MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.", ]; +const CODEC_BUDGET = + "Complete evidence has a 4 MiB map, 32 MiB reply, 262144 decoded rows/segments and 64-level section budget. The owned codec has a 20-second deadline"; +/** Bun accepts but ignores the codec's V8 heap flags; Windows enforces a commit limit instead. */ +const codecBudgetLimitation = ( + runtime: WebSourceMapReport["runtime"], +): string => { + if (runtime.id === "node-v8") + return `${CODEC_BUDGET}, ${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)} MiB old-generation heap and one V8 worker; cleanup may extend the deadline. V8 heap limits do not establish aggregate RSS limits.`; + return runtime.process_memory_limit_bytes === null + ? `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags, so no codec heap limit is established.` + : `${CODEC_BUDGET}; cleanup may extend the deadline. The codec ran on Bun, which does not enforce REA's V8 heap flags; a Windows Job Object limited its committed memory to ${String(runtime.process_memory_limit_bytes)} bytes, which is not a heap or RSS limit.`; +}; /** Join verified captured bytes with an explicitly selected source-map point. */ export class WebSourceLocationService { @@ -110,6 +121,10 @@ export class WebSourceLocationService { if (!report.ok) return report; if (options?.signal?.aborted) return err(new AnalysisCancelledError(OPERATION)); + const limitations = [ + ...LIMITATIONS, + codecBudgetLimitation(report.value.runtime), + ]; const normalized = webSourceLocationResultSchema.safeParse({ ...report.value, manifest: capturedWebManifestIdentity(data), @@ -122,7 +137,7 @@ export class WebSourceLocationService { generated_offset: generatedOffset, source_authenticity: "unknown", execution: "unknown", - limitations: LIMITATIONS, + limitations, }); if (!normalized.success) return invalidOutput(normalized.error.message); return ok( @@ -144,7 +159,7 @@ export class WebSourceLocationService { rawResult: { source_map_text: map.text }, confidence: "derived", authority: "historical-reference", - limitations: LIMITATIONS, + limitations, locations: [ data.sourceFile.path, data.manifestFile.path, diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index f7cd77b2cd..ff7be71a6c 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -4,6 +4,7 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; import { inspectWebNetworkCaptureInputSchema } from "../../domain/webNetworkCapture.js"; import { historicalCaptureFailure } from "./CaptureFailures.js"; +import { HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js"; import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; @@ -51,6 +52,99 @@ it.each([ }, }); expect(JSON.stringify(projected)).not.toContain("rea doctor"); + // Bun ignores the decoder's V8 heap flags, so no fixed heap is reported. + const heapLimits = { old_generation_heap_mib: 192, semi_space_heap_mib: 8 }; + if (process.versions.bun === undefined) + expect(projected).toMatchObject({ + details: { reported_limits: heapLimits }, + }); + else + expect(projected).not.toMatchObject({ + details: { reported_limits: { old_generation_heap_mib: 192 } }, + }); + }, +); + +// Measured Bun 1.4.2 outcomes at a Job Object commit limit: a 512 MiB limit +// panicked with exit 3 and a 256 MiB limit threw a JavaScript out-of-memory +// exception with exit 1. Only the job notification identifies both. +it.each([ + { label: "a runtime panic", exitCode: 3, stderr: "panic: out of memory" }, + { + label: "a JavaScript exception", + exitCode: 1, + stderr: "RangeError: Out of memory", + }, +])( + "reports a reached Job Object memory limit after $label", + ({ exitCode, stderr }) => { + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: "/selected/input.har", + format: "har", + }); + const snapshot = { + stdout: { text: "", bytes: 0, observedBytes: 0 }, + stderr: { + text: stderr, + bytes: Buffer.byteLength(stderr), + observedBytes: Buffer.byteLength(stderr), + }, + exitCode, + signal: null, + }; + const projected = analysisErrorProjectionSchema.parse( + projectAnalysisError( + historicalCaptureFailure( + input, + new OwnedCommandFailure("process", "Decoder failed", { + ...snapshot, + memoryLimitReached: true, + }), + "decoder", + ), + ), + ); + expect(projected).toMatchObject({ + code: "resource_constraint", + message: expect.stringContaining("Windows Job Object memory limit"), + details: { + resource: "memory", + reported_limits: { + format: "har", + observed_exit_code: exitCode, + observed_signal: null, + }, + captured_output: { stderr }, + }, + }); + // The enforced limit is reported only where Bun runs on Windows. + if (HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null) + expect(JSON.stringify(projected)).not.toContain( + "process_memory_limit_bytes", + ); + else + expect(projected).toMatchObject({ + details: { + reported_limits: { + process_memory_limit_bytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES, + }, + }, + remediation: { + action: expect.stringContaining( + `${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes`, + ), + }, + }); + // The same exit without the job notification remains an ordinary failure. + expect( + projectAnalysisError( + historicalCaptureFailure( + input, + new OwnedCommandFailure("process", "Decoder failed", snapshot), + "decoder", + ), + ), + ).toMatchObject({ code: "execution_failure" }); }, ); diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 13a9a781c5..78a5f0a43d 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -18,9 +18,17 @@ import { type InspectWebNetworkCaptureInput, } from "../../domain/webNetworkCapture.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; -import { HAR_CAPTURE_HEAP_LIMITS } from "./CaptureRelease.js"; +import { + HAR_CAPTURE_HEAP_ENFORCED, + HAR_CAPTURE_HEAP_LIMITS, + HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES, +} from "./CaptureRelease.js"; const OPERATION = "inspect_web_network_capture"; +/** Reason for a decoder whose owned process tree reached its Job Object commit limit. */ +export const CAPTURE_PROCESS_MEMORY_LIMIT_REASON = + "The capture decoder reached its Windows Job Object memory limit"; + /** Preserve the origin and typed cause of a historical capture failure. */ export const historicalCaptureFailure = ( input: InspectWebNetworkCaptureInput, @@ -57,6 +65,18 @@ export const historicalCaptureFailure = ( ); if (cause.reason === "output-limit") return new AnalysisOutputError(OPERATION, cause.message); + // The job's own notification is runtime-independent; Bun's exit status + // and diagnostics for an allocation failure are not. + if ( + phase === "decoder" && + cause.reason === "process" && + cause.snapshot?.memoryLimitReached === true + ) + return captureMemoryFailure( + input, + CAPTURE_PROCESS_MEMORY_LIMIT_REASON, + cause, + ); if ( phase === "decoder" && input.format === "har" && @@ -176,12 +196,16 @@ export const captureMemoryFailure = ( format: input.format, maximum_input_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes, maximum_reply_bytes: WEB_NETWORK_CAPTURE_LIMITS.outputBytes, - ...(input.format === "har" + ...(input.format === "har" && HAR_CAPTURE_HEAP_ENFORCED ? { old_generation_heap_mib: HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB, semi_space_heap_mib: HAR_CAPTURE_HEAP_LIMITS.semiSpaceMiB, } : {}), + ...(input.format === "har" && + HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null + ? { process_memory_limit_bytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } + : {}), ...(cause instanceof OwnedCommandFailure ? { observed_exit_code: cause.snapshot?.exitCode ?? null, @@ -191,7 +215,7 @@ export const captureMemoryFailure = ( }, { cause, - remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format === "har" ? ` REA's HAR old-generation heap is fixed at ${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB} MiB; inherited NODE_OPTIONS cannot raise it.` : ""}`, + remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format === "har" && HAR_CAPTURE_HEAP_ENFORCED ? ` REA's HAR old-generation heap is fixed at ${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB} MiB; inherited NODE_OPTIONS cannot raise it.` : ""}${input.format === "har" && HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null ? ` REA's HAR decoder process commit limit is fixed at ${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes.` : ""}`, ...(cause instanceof OwnedCommandFailure && cause.snapshot !== null ? { capturedOutput: { diff --git a/src/browser/history/CaptureRelease.ts b/src/browser/history/CaptureRelease.ts index e9ac3e3662..d8af643aac 100644 --- a/src/browser/history/CaptureRelease.ts +++ b/src/browser/history/CaptureRelease.ts @@ -1,3 +1,6 @@ +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; + /** Exact upstream package profile for unchanged offline HAR validation and numeric parsing. */ export const HAR_CAPTURE_PROVIDER_IDENTITY = { id: "har-schema/lossless-json", @@ -11,6 +14,14 @@ export const HAR_CAPTURE_HEAP_LIMITS = { semiSpaceMiB: 8, } as const; +/** The HAR decoder runs on this executable; Bun accepts but ignores V8 heap flags. */ +export const HAR_CAPTURE_HEAP_ENFORCED = + javaScriptRuntime(process.versions).id === "node"; + +/** Windows Job Object commit limit replacing the ignored heap flags under Bun; otherwise null. */ +export const HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES = + javaScriptWorkerMemoryLimitBytes(HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB); + /** Native executable profile verified with unchanged mitmproxy raw-state decoding. */ export const MITMPROXY_CAPTURE_PROVIDER_IDENTITY = { id: "mitmproxy-native-tnetstring", diff --git a/src/browser/history/HarCaptureAdapter.ts b/src/browser/history/HarCaptureAdapter.ts index bae33fa7e4..9a52042579 100644 --- a/src/browser/history/HarCaptureAdapter.ts +++ b/src/browser/history/HarCaptureAdapter.ts @@ -3,15 +3,18 @@ import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAd import { HAR_CAPTURE_PROVIDER_IDENTITY, HAR_CAPTURE_HEAP_LIMITS, + HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES, } from "./CaptureRelease.js"; /** HAR's parser/schema packages run in one bounded Node heap, without network or target execution. */ export class HarCaptureAdapter implements HistoricalCaptureFormatAdapter { readonly format = "har"; readonly identity = HAR_CAPTURE_PROVIDER_IDENTITY; - async command( - requestPath: string, - ): Promise<{ command: string; arguments: readonly string[] }> { + async command(requestPath: string): Promise<{ + command: string; + arguments: readonly string[]; + memoryLimitBytes?: number; + }> { return { command: process.execPath, arguments: [ @@ -22,6 +25,9 @@ export class HarCaptureAdapter implements HistoricalCaptureFormatAdapter { fileURLToPath(new URL("./HarCaptureProcess.js", import.meta.url)), requestPath, ], + ...(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null + ? {} + : { memoryLimitBytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES }), }; } } diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index bb60625010..20258c1773 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -1,7 +1,13 @@ import { + CAPTURE_PROCESS_MEMORY_LIMIT_REASON, historicalCaptureFailure, captureMemoryFailure, } from "./CaptureFailures.js"; +import { + HAR_CAPTURE_HEAP_ENFORCED, + HAR_CAPTURE_HEAP_LIMITS, + HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES, +} from "./CaptureRelease.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js"; import { randomUUID } from "node:crypto"; @@ -113,7 +119,7 @@ export class HistoricalCaptureDecoder { UV_THREADPOOL_SIZE: "1", }; delete environment.NODE_OPTIONS; - await runOwnedCommand( + const completed = await runOwnedCommand( { ...command, runId: `rea-web-capture-${randomUUID()}`, @@ -126,6 +132,10 @@ export class HistoricalCaptureDecoder { }, options?.signal === undefined ? {} : { signal: options.signal }, ); + // A decoder that survived a failed allocation may still reply; its + // reply is not trusted as complete once the limit was reached. + if (completed.memoryLimitReached === true) + throw captureMemoryFailure(input, CAPTURE_PROCESS_MEMORY_LIMIT_REASON); let reply: z.output; try { const replyFile = await readStableArtifact( @@ -204,7 +214,7 @@ export class HistoricalCaptureDecoder { "This is retained producer evidence, without live browser transaction IDs, scenario provenance, execution attribution or deployment authenticity. Recorded URLs are never fetched.", "Every record is decoded before selection; no partial success is returned on malformed input or a resource limit.", "Known authentication headers/cookies and explicitly marked literal values are excluded. Unknown extension names do not establish sensitivity.", - "A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. HAR uses a 192 MiB Node old-generation heap; native decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.", + `A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. ${harMemoryLimitation()}; native decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.`, ], }); } catch (cause: unknown) { @@ -244,6 +254,15 @@ export class HistoricalCaptureDecoder { } } +/** The HAR decoder runs on this executable; only Node.js enforces its V8 heap flags. */ +const harMemoryLimitation = (): string => { + if (HAR_CAPTURE_HEAP_ENFORCED) + return `HAR uses a ${String(HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB)} MiB Node old-generation heap`; + return HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null + ? "HAR runs on Bun, which does not enforce REA's V8 heap flags" + : `HAR runs on Bun, which does not enforce REA's V8 heap flags, under a ${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)}-byte Windows Job Object commit limit`; +}; + const replyFailureReason = (cause: unknown): string => { if (cause instanceof ArtifactReaderFailure) return cause.message; if (cause instanceof z.ZodError) diff --git a/src/browser/history/HistoricalCaptureFormatAdapter.ts b/src/browser/history/HistoricalCaptureFormatAdapter.ts index 55281b5ec6..811ea56e2d 100644 --- a/src/browser/history/HistoricalCaptureFormatAdapter.ts +++ b/src/browser/history/HistoricalCaptureFormatAdapter.ts @@ -8,8 +8,13 @@ export interface HistoricalCaptureFormatAdapter { readonly name: string; readonly version: string; }; + /** `memoryLimitBytes` requests an enforced commit limit for the decoder process tree. */ command( requestPath: string, runtimePath: string, - ): Promise<{ command: string; arguments: readonly string[] }>; + ): Promise<{ + command: string; + arguments: readonly string[]; + memoryLimitBytes?: number; + }>; } diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.ts b/src/domain/javascript/javascriptAnalysisResourceControls.ts index 56886457b6..7bd66fb1c1 100644 --- a/src/domain/javascript/javascriptAnalysisResourceControls.ts +++ b/src/domain/javascript/javascriptAnalysisResourceControls.ts @@ -15,6 +15,7 @@ export const javaScriptAnalysisCoverageLimits = ( const result: ApplicationCoverage["limits"] = []; for (const key of [ "worker_heap_limit_bytes", + "worker_process_memory_limit_bytes", "worker_timeout_ms", "parent_result_budget_bytes", "semantic_result_budget_bytes", @@ -42,7 +43,7 @@ export const javaScriptAnalysisResourceControlsSchema = z.strictObject({ .max(16_384) .optional() .describe( - "Maximum V8 old-space heap in MiB for the isolated JavaScript source analyzer; defaults to 1024. This does not change the CLI/MCP process heap.", + "Maximum V8 old-space heap in MiB for the isolated JavaScript source analyzer; defaults to 1024. This does not change the CLI/MCP process heap. When REA runs on Bun, which ignores V8 heap flags, Windows instead limits the analyzer's committed memory to this budget plus a 512 MiB runtime reserve; other Bun hosts enforce no limit.", ), analysis_timeout_ms: z .number() diff --git a/src/domain/runtimeVersion.test.ts b/src/domain/runtimeVersion.test.ts index 307c6778dc..ea968ae562 100644 --- a/src/domain/runtimeVersion.test.ts +++ b/src/domain/runtimeVersion.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { supportsNodeVersion } from "./runtimeVersion.js"; +import { javaScriptRuntime, supportsNodeVersion } from "./runtimeVersion.js"; describe("supported Node.js versions", () => { it.each([ @@ -24,3 +24,17 @@ describe("supported Node.js versions", () => { expect(supportsNodeVersion(version)).toBe(expected); }); }); + +describe("JavaScript runtime identity", () => { + it("distinguishes Bun from Node.js by its own version", () => { + expect(javaScriptRuntime({ node: "24.18.0" })).toEqual({ + id: "node", + version: "24.18.0", + }); + expect(javaScriptRuntime({ node: "26.3.0", bun: "1.4.2" })).toEqual({ + id: "bun", + version: "1.4.2", + nodeCompatibilityVersion: "26.3.0", + }); + }); +}); diff --git a/src/domain/runtimeVersion.ts b/src/domain/runtimeVersion.ts index 09fbc54e34..0a9f19cae0 100644 --- a/src/domain/runtimeVersion.ts +++ b/src/domain/runtimeVersion.ts @@ -10,3 +10,25 @@ export const SUPPORTED_NODE_VERSION_PROSE = /** Return whether a Node.js version satisfies REA's tested runtime families. */ export const supportsNodeVersion = (version: string): boolean => satisfies(version, SUPPORTED_NODE_VERSION_RANGE); + +/** JavaScript runtime executing REA. Bun also reports a Node.js compatibility version. */ +export type JavaScriptRuntime = + | { readonly id: "node"; readonly version: string } + | { + readonly id: "bun"; + readonly version: string; + readonly nodeCompatibilityVersion: string; + }; + +/** Identify the runtime from `process.versions`; only Bun reports `bun`. */ +export const javaScriptRuntime = (versions: { + readonly node: string; + readonly bun?: string | undefined; +}): JavaScriptRuntime => + versions.bun === undefined + ? { id: "node", version: versions.node } + : { + id: "bun", + version: versions.bun, + nodeCompatibilityVersion: versions.node, + }; diff --git a/src/domain/webSourceLocation.ts b/src/domain/webSourceLocation.ts index b93eae6e32..54db26209e 100644 --- a/src/domain/webSourceLocation.ts +++ b/src/domain/webSourceLocation.ts @@ -79,11 +79,22 @@ export const webSourceMapReportSchema = z.strictObject({ source_map_sha256: digestSchema, url_context: z.string().min(1), engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }), - runtime: z.strictObject({ - id: z.string().min(1), - version: z.string().min(1), - v8_heap_limit_bytes: z.number().int().min(1), - }), + runtime: z.discriminatedUnion("id", [ + z.strictObject({ + id: z.literal("node-v8"), + version: z.string().min(1), + v8_heap_limit_bytes: z.number().int().min(1), + }), + // Bun reports V8-shaped heap statistics but does not enforce V8 heap flags. + // On Windows REA instead enforces a Job Object commit limit on the codec. + z.strictObject({ + id: z.literal("bun"), + version: z.string().min(1), + node_compatibility_version: z.string().min(1), + v8_heap_limit_bytes: z.null(), + process_memory_limit_bytes: z.number().int().min(1).nullable(), + }), + ]), format: z.enum(["regular", "indexed"]), reported_file: z.string().nullable(), lookup: z.strictObject({ @@ -107,6 +118,8 @@ export const WEB_SOURCE_MAP_LIMITS = { decodedRecords: 262144, sectionDepth: 64, decodeTimeoutMs: 20000, + /** V8 old-generation heap for the codec, and its Bun process budget. */ + codecHeapMiB: 192, } as const; export type WebSourceMapReport = z.output; diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index bb77e1bffb..e1ea7fe375 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -54,7 +54,6 @@ const doctorHost = ( createDoctorHostFixture({ platform: "linux", architecture: "x64", - nodeVersion: "24.18.0", macosVersion: () => Promise.resolve(undefined), linuxDistribution: () => Promise.resolve({ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index cfbbc0083f..4113ec8db4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -26,10 +26,12 @@ import { import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ok, err, type Result } from "../../domain/result.js"; +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; import { PrivateRuntimeRoot, PrivateRuntimeRootUnavailableError, } from "../../process/PrivateRuntimeRoot.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { ProviderProcessSupervisor, spawnOwnedProviderProcess, @@ -58,6 +60,9 @@ const OPERATION = "analyze_javascript_application"; const PROVIDER = "javascript-static-analysis"; const MIB = 1024 * 1024; const pendingCleanup = new Set(); +/** The worker runs on this executable; Bun accepts but ignores V8 heap flags. */ +const WORKER_HEAP_FLAGS_ENFORCED = + javaScriptRuntime(process.versions).id === "node"; /** One sequential owned worker, retained between source files and closed after analysis. */ export class JavaScriptAnalysisWorker { @@ -78,6 +83,8 @@ export class JavaScriptAnalysisWorker { #retainedStaticBytes = 0; #sourcesRemaining: number | undefined; readonly #resultBudgetBytes: number; + /** Null when the runtime enforces the V8 heap flags instead. */ + readonly #processMemoryLimitBytes: number | null; readonly #onStdout = (chunk: Buffer | string): void => { try { this.#pending += @@ -129,6 +136,9 @@ export class JavaScriptAnalysisWorker { "JavaScript worker source count must be a nonnegative safe integer", ); this.#sourcesRemaining = sourceCount; + this.#processMemoryLimitBytes = javaScriptWorkerMemoryLimitBytes( + limits.heapMb, + ); // Reserve host-side control, schema/Evidence work and a maximum record's // transient copies. Admission also accounts for retained earlier files. this.#resultBudgetBytes = Math.max( @@ -355,6 +365,9 @@ export class JavaScriptAnalysisWorker { }); break; } + // An allocation failure may surface as a protocol failure or be caught + // inside the worker; any reached limit makes this file's result partial. + if (this.#memoryLimitReached()) throw this.#memoryFailure(file.path); } catch (cause: unknown) { const error = this.#failure(cause, file.path, deadline, signal); this.#lastFailure = error; @@ -449,6 +462,9 @@ export class JavaScriptAnalysisWorker { cwd: this.#root.path, hostEnvironment: environment, stdin: "pipe", + ...(this.#processMemoryLimitBytes === null + ? {} + : { memoryLimitBytes: this.#processMemoryLimitBytes }), signal: deadline.signal, }); this.#supervisor = new ProviderProcessSupervisor( @@ -468,7 +484,12 @@ export class JavaScriptAnalysisWorker { OPERATION, "JavaScript worker emitted facts before its startup handshake", ); - this.#heapLimitBytes = ready.heap_limit_bytes; + if ((ready.heap_limit_bytes === null) === WORKER_HEAP_FLAGS_ENFORCED) + throw new AnalysisOutputError( + OPERATION, + "JavaScript worker heap limit does not match the runtime that launched it", + ); + this.#heapLimitBytes = ready.heap_limit_bytes ?? 0; this.#ready = true; } @@ -494,25 +515,27 @@ export class JavaScriptAnalysisWorker { if (!(await this.#supervisor?.waitForOutputClose(10))) continue; if (this.#frames.length !== 0) continue; const drained = this.#supervisor?.snapshot() ?? snapshot; - throw /heap out of memory|heap limit|allocation failed/iu.test( - drained.stderr.text, - ) - ? new AnalysisResourceConstraintError( - OPERATION, - "memory", - `Owned JavaScript worker exhausted its heap while analyzing ${path} during ${this.#phase}`, - this.#limits(), - { capturedOutput: this.#captured() }, - ) - : new ProviderAdapterError(PROVIDER, OPERATION, { - capturedOutput: this.#captured(), - diagnostics: { - source_path: path, - phase: this.#phase, - exit_code: drained.exitCode ?? null, - signal: drained.signal ?? null, - }, - }); + throw this.#memoryLimitReached() + ? this.#memoryFailure(path) + : /heap out of memory|heap limit|allocation failed/iu.test( + drained.stderr.text, + ) + ? new AnalysisResourceConstraintError( + OPERATION, + "memory", + `Owned JavaScript worker exhausted its heap while analyzing ${path} during ${this.#phase}`, + this.#limits(), + { capturedOutput: this.#captured() }, + ) + : new ProviderAdapterError(PROVIDER, OPERATION, { + capturedOutput: this.#captured(), + diagnostics: { + source_path: path, + phase: this.#phase, + exit_code: drained.exitCode ?? null, + signal: drained.signal ?? null, + }, + }); } await waitForAbortableDelay(10, deadline.signal); } @@ -585,13 +608,35 @@ export class JavaScriptAnalysisWorker { #limits() { return { - worker_old_space_mb: this.limits.heapMb, - ...(this.#heapLimitBytes === 0 + ...(WORKER_HEAP_FLAGS_ENFORCED + ? { + worker_old_space_mb: this.limits.heapMb, + ...(this.#heapLimitBytes === 0 + ? {} + : { worker_heap_limit_bytes: this.#heapLimitBytes }), + } + : // Bun enforces no worker heap limit; report it as unavailable. + { worker_heap_limit_bytes: null }), + ...(this.#processMemoryLimitBytes === null ? {} - : { worker_heap_limit_bytes: this.#heapLimitBytes }), + : { + worker_process_memory_limit_bytes: this.#processMemoryLimitBytes, + }), worker_timeout_ms: this.limits.timeoutMs, }; } + #memoryLimitReached(): boolean { + return this.#supervisor?.launch.process.memoryLimitReached === true; + } + #memoryFailure(path: string): AnalysisResourceConstraintError { + return new AnalysisResourceConstraintError( + OPERATION, + "memory", + `Owned JavaScript worker reached its ${this.#processMemoryLimitBytes === null ? "" : `${String(this.#processMemoryLimitBytes)}-byte `}process memory limit while analyzing ${path} during ${this.#phase}`, + this.#limits(), + { capturedOutput: this.#captured() }, + ); + } #remainingResultBudget(): number { // Future sources need their compact observations before current modules // consume capacity with expanded relationships. A final or sole source @@ -648,6 +693,13 @@ export class JavaScriptAnalysisWorker { return new AnalysisTimeoutError(OPERATION, this.limits.timeoutMs, { capturedOutput: this.#captured(), }); + // A reached process limit explains the worker's exit, broken pipe or + // truncated frame better than the symptom it produced. + if ( + this.#memoryLimitReached() && + !(cause instanceof AnalysisResourceConstraintError) + ) + return this.#memoryFailure(path); if (cause instanceof AnalysisError) return cause; if ( cause instanceof z.ZodError || diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index cf8e905bdd..110dfe3b70 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -39,7 +39,8 @@ const identity = { id: z.uuid() }; export const javaScriptWorkerMessageSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("ready"), - heap_limit_bytes: z.number().int().positive(), + // Null when the worker runtime (Bun) does not enforce V8 heap flags. + heap_limit_bytes: z.number().int().positive().nullable(), }), z.strictObject({ kind: z.literal("phase"), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts index 65f69829b1..25ab14e64a 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { createInterface } from "node:readline"; import { getHeapStatistics } from "node:v8"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js"; @@ -58,7 +59,10 @@ export const serveJavaScriptAnalysisWorker = async ( const requests = input[Symbol.asyncIterator](); await send({ kind: "ready", - heap_limit_bytes: getHeapStatistics().heap_size_limit, + heap_limit_bytes: + javaScriptRuntime(process.versions).id === "node" + ? getHeapStatistics().heap_size_limit + : null, }); try { for (;;) { diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index ea1a9fca2f..e869541066 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -8,6 +8,7 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisOutputError, + AnalysisResourceConstraintError, AnalysisTimeoutError, } from "../../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; @@ -20,6 +21,7 @@ import { PrivateRuntimeRootUnavailableError, } from "../../process/PrivateRuntimeRoot.js"; import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js"; import { ProviderProcessSupervisor, @@ -31,6 +33,9 @@ import { prepareProcessOwnershipInspection } from "../../process/ProcessOwnershi const OPERATION = "trace_web_source_location"; const PROVIDER = "source-map-decoder"; +/** Process commit limit enforced on the codec; null where V8 heap flags apply. */ +export const SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES = + javaScriptWorkerMemoryLimitBytes(WEB_SOURCE_MAP_LIMITS.codecHeapMiB); /** Launcher seam retains the actual codec protocol and ownership supervisor. */ export type SourceMapCodecLauncher = ( options: OwnedProviderProcessSpawnOptions, @@ -147,7 +152,7 @@ class OwnedCodec implements SourceMapCleanupOwner { )({ command: process.execPath, arguments: [ - "--max-old-space-size=192", + `--max-old-space-size=${String(WEB_SOURCE_MAP_LIMITS.codecHeapMiB)}`, "--max-semi-space-size=8", "--v8-pool-size=1", "--single-threaded", @@ -158,6 +163,9 @@ class OwnedCodec implements SourceMapCleanupOwner { runId: this.#runId, cwd: this.#runtime.path, hostEnvironment: environment, + ...(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null + ? {} + : { memoryLimitBytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES }), signal: this.deadline.signal, }); this.#supervisor = new ProviderProcessSupervisor( @@ -191,6 +199,23 @@ class OwnedCodec implements SourceMapCleanupOwner { while (!(await supervisor.waitForOutputClose(50))) this.check(); this.check(); const snapshot = supervisor.snapshot(); + // No partial codec result exists; a reached limit fails the whole trace. + if (snapshot.memoryLimitReached === true) + throw new AnalysisResourceConstraintError( + OPERATION, + "memory", + `Source-map codec reached its ${SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? "" : `${String(SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES)}-byte `}process memory limit for ${this.path}; no partial evidence was returned.`, + { + codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + }, + { + capturedOutput: { + stdout: "", + stderr: snapshot.stderr.text, + truncated: snapshot.diagnosticTruncated ?? false, + }, + }, + ); if (snapshot.exitCode !== 0 || snapshot.signal !== null) throw new ProviderAdapterError(PROVIDER, OPERATION, { diagnostics: { diff --git a/src/javascript/sourceMaps/SourceMapDecoder.ts b/src/javascript/sourceMaps/SourceMapDecoder.ts index 8db70ee472..fc26cde14e 100644 --- a/src/javascript/sourceMaps/SourceMapDecoder.ts +++ b/src/javascript/sourceMaps/SourceMapDecoder.ts @@ -9,6 +9,7 @@ import { } from "../../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; import { WEB_SOURCE_MAP_LIMITS, type WebSourceMapReport, @@ -18,6 +19,7 @@ import { sourceMapCodecReplySchema, } from "./SourceMapCodecProtocol.js"; import { + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, SourceMapCleanupFailure, runSourceMapCommand, type SourceMapCleanupOwner, @@ -140,14 +142,10 @@ export class SourceMapDecoder implements WebSourceMapPort { return err(new AnalysisCancelledError(OPERATION)); if (reply.data.state === "failure") return codecFailure(input.path, reply.data.reason, reply.data.message); - if (reply.data.report.runtime.v8_heap_limit_bytes > 256 * 1024 * 1024) - return err( - new AnalysisOutputError( - OPERATION, - "Codec reported a V8 heap limit exceeding its independent process budget.", - ), - ); - return ok(reply.data.report); + const runtimeFailure = codecRuntimeFailure(reply.data.report.runtime); + return runtimeFailure === undefined + ? ok(reply.data.report) + : err(new AnalysisOutputError(OPERATION, runtimeFailure)); } async close(): Promise { @@ -173,6 +171,30 @@ export class SourceMapDecoder implements WebSourceMapPort { return undefined; } } +/** + * The codec runs on this process's executable. Only a V8 report carries a heap + * limit; a Bun report must name the process limit its launcher enforced. + */ +const codecRuntimeFailure = ( + runtime: WebSourceMapReport["runtime"], +): string | undefined => { + if ( + runtime.id !== + (javaScriptRuntime(process.versions).id === "bun" ? "bun" : "node-v8") + ) + return `Codec reported runtime ${runtime.id}, not the runtime that launched it.`; + if ( + runtime.id === "node-v8" && + runtime.v8_heap_limit_bytes > 256 * 1024 * 1024 + ) + return "Codec reported a V8 heap limit exceeding its independent process budget."; + if ( + runtime.id === "bun" && + runtime.process_memory_limit_bytes !== SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES + ) + return "Codec reported a process memory limit other than the one its launcher enforced."; + return undefined; +}; const codecFailure = ( path: string, reason: "format" | "unsupported" | "limit", diff --git a/src/javascript/sourceMaps/TraceSourceMap.ts b/src/javascript/sourceMaps/TraceSourceMap.ts index 14755d2ec8..8d2c81fc92 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.ts @@ -8,6 +8,8 @@ import { type SourceMapSegment, } from "@jridgewell/trace-mapping"; import { z } from "zod"; +import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { webSourceOffset } from "../../domain/webSourcePosition.js"; import { webSourceMapReportSchema, @@ -72,15 +74,29 @@ export const traceSourceMap = ( const version = z .object({ version: z.string().min(1) }) .parse(metadata).version; + const runtime = javaScriptRuntime(process.versions); return webSourceMapReportSchema.parse({ source_map_sha256: createHash("sha256").update(text).digest("hex"), url_context: url, engine: { id: "jridgewell-trace-mapping", version }, - runtime: { - id: "node-v8", - version: process.version, - v8_heap_limit_bytes: getHeapStatistics().heap_size_limit, - }, + // Bun reports V8-shaped heap statistics but ignores the codec's heap flags. + // Its launcher applies the same runtime policy, and the parent verifies it. + runtime: + runtime.id === "node" + ? { + id: "node-v8", + version: process.version, + v8_heap_limit_bytes: getHeapStatistics().heap_size_limit, + } + : { + id: "bun", + version: runtime.version, + node_compatibility_version: runtime.nodeCompatibilityVersion, + v8_heap_limit_bytes: null, + process_memory_limit_bytes: javaScriptWorkerMemoryLimitBytes( + WEB_SOURCE_MAP_LIMITS.codecHeapMiB, + ), + }, format: inspected.format, reported_file: inspected.reportedFile, lookup: { diff --git a/src/process/JavaScriptWorkerMemoryLimit.test.ts b/src/process/JavaScriptWorkerMemoryLimit.test.ts new file mode 100644 index 0000000000..629f0a3e5d --- /dev/null +++ b/src/process/JavaScriptWorkerMemoryLimit.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; +import { + BUN_WORKER_RUNTIME_RESERVE_BYTES, + javaScriptWorkerMemoryLimitBytes, +} from "./JavaScriptWorkerMemoryLimit.js"; + +const MIB = 1024 * 1024; +const bun = { node: "24.3.0", bun: "1.4.2" }; +const node = { node: "24.18.0" }; + +it("limits a Bun worker on Windows to its heap budget plus Bun's runtime reserve", () => { + expect(BUN_WORKER_RUNTIME_RESERVE_BYTES).toBe(512 * MIB); + expect( + javaScriptWorkerMemoryLimitBytes(192, { platform: "win32", versions: bun }), + ).toBe(704 * MIB); + expect( + javaScriptWorkerMemoryLimitBytes(1024, { + platform: "win32", + versions: bun, + }), + ).toBe(1536 * MIB); +}); + +it.each([ + { label: "Node.js on Windows", platform: "win32", versions: node }, + { label: "Node.js on Linux", platform: "linux", versions: node }, + { label: "Bun on Linux", platform: "linux", versions: bun }, + { label: "Bun on macOS", platform: "darwin", versions: bun }, +] as const)("applies no process limit for $label", ({ platform, versions }) => { + expect(javaScriptWorkerMemoryLimitBytes(192, { platform, versions })).toBe( + null, + ); +}); diff --git a/src/process/JavaScriptWorkerMemoryLimit.ts b/src/process/JavaScriptWorkerMemoryLimit.ts new file mode 100644 index 0000000000..5ab76fda17 --- /dev/null +++ b/src/process/JavaScriptWorkerMemoryLimit.ts @@ -0,0 +1,41 @@ +import { javaScriptRuntime } from "../domain/runtimeVersion.js"; + +const MIB = 1024 * 1024; + +/** + * Commit reserved for Bun itself above a worker's heap budget. + * + * Measured on Windows x64 (Bun 1.4.2, 32 logical processors) as the peak Job + * Object commit of each REA worker on a trivial input: `bun -e 1` commits + * 52 MiB, while the source-map codec (278-368 MiB), HAR decoder (300-345 MiB) + * and JavaScript analysis worker (321-346 MiB) commit up to 368 MiB before any + * real workload because JavaScriptCore commits parsed and compiled module code + * and per-thread runtime state. 512 MiB covers that peak with about 40% + * margin for hosts with other processor counts or module layouts. + */ +export const BUN_WORKER_RUNTIME_RESERVE_BYTES = 512 * MIB; + +/** Runtime facts that decide whether V8 heap flags bound a worker. */ +export interface JavaScriptWorkerHost { + readonly platform: NodeJS.Platform; + readonly versions: { + readonly node: string; + readonly bun?: string | undefined; + }; +} + +/** + * Process commit limit for a JavaScript worker launched on `process.execPath`. + * + * Node.js enforces each worker's V8 heap flags, so it receives no process + * limit (null) and its launch is unchanged. Bun ignores those flags; on Windows + * the owned Job Object then enforces the worker's heap budget plus Bun's + * runtime reserve. Bun elsewhere has no owned enforcement and also gets null. + */ +export const javaScriptWorkerMemoryLimitBytes = ( + heapBudgetMiB: number, + host: JavaScriptWorkerHost = process, +): number | null => + host.platform === "win32" && javaScriptRuntime(host.versions).id === "bun" + ? heapBudgetMiB * MIB + BUN_WORKER_RUNTIME_RESERVE_BYTES + : null; diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 28631c47cd..b6765b93f7 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -30,6 +30,11 @@ export interface OwnedProviderProcessSpawnOptions { readonly hostEnvironment?: NodeJS.ProcessEnv; /** Opt into a writable protocol stream; other providers retain ignored stdin. */ readonly stdin?: "pipe"; + /** + * Commit limit in bytes for the whole owned process tree, enforced by its + * Windows Job Object. Other launchers cannot enforce it and refuse it. + */ + readonly memoryLimitBytes?: number; /** Cancels native ownership preparation before provider process creation. */ readonly signal?: AbortSignal; } @@ -52,6 +57,8 @@ export interface ProviderProcessHandle extends Pick< readonly stderr: Readable | null; readonly exitCode: number | null; readonly signalCode: NodeJS.Signals | null; + /** True once an enforced process memory limit reported an allocation failure. */ + readonly memoryLimitReached?: boolean; kill(signal?: NodeJS.Signals | number): boolean; } @@ -89,6 +96,8 @@ export interface ProviderProcessSnapshot { }; readonly exitCode: number | null | undefined; readonly signal: NodeJS.Signals | null | undefined; + /** Present only when the process tree reached its enforced memory limit. */ + readonly memoryLimitReached?: true; } /** Resource-safe events emitted without imposing provider protocol semantics. */ @@ -162,7 +171,12 @@ export const spawnOwnedProviderProcess = async ( options.cwd, environment, options.windowsVerbatimArguments ?? false, - options.stdin === "pipe", + { + protocolStdin: options.stdin === "pipe", + ...(options.memoryLimitBytes === undefined + ? {} + : { memoryLimitBytes: options.memoryLimitBytes }), + }, ); return { process: child, @@ -175,6 +189,10 @@ export const spawnOwnedProviderProcess = async ( cleanup: () => child.cleanup(), }; } + if (options.memoryLimitBytes !== undefined) + throw new Error( + "Owned process memory limits require the Windows Job Object launcher; no limit can be enforced on this host.", + ); const child = spawn(options.command, [...options.arguments], { shell: false, windowsHide: true, @@ -277,6 +295,9 @@ export class ProviderProcessSupervisor { stderr: this.#stderr.snapshot(this.#stderrObservedBytes), exitCode: this.#exitObservation?.code, signal: this.#exitObservation?.signal, + ...(this.launch.process.memoryLimitReached === true + ? { memoryLimitReached: true } + : {}), }; } diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index e4b46b0b7c..b24f7646e8 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -33,3 +33,40 @@ it("exit does not establish complete output when a producer closes its streams l supervisor.dispose(); } }); + +it("reports a reached process memory limit only when the handle observed one", () => { + let reached = false; + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + // A live accessor, as on the owned Windows process handle. + Object.defineProperty(producer, "memoryLimitReached", { get: () => reached }); + const exitSnapshots: unknown[] = []; + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { + onDiagnostic: (event) => { + if (event.type === "exit") exitSnapshots.push(event.snapshot); + }, + }, + ); + try { + expect(supervisor.snapshot()).not.toHaveProperty("memoryLimitReached"); + // Owned launchers record the job notification before emitting exit. + reached = true; + producer.emit("exit", 3, null); + expect(supervisor.snapshot()).toMatchObject({ + exitCode: 3, + memoryLimitReached: true, + }); + expect(exitSnapshots).toEqual([ + expect.objectContaining({ memoryLimitReached: true }), + ]); + } finally { + supervisor.dispose(); + } +}); diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts index cd3ae7de2d..9e8cfe6b46 100644 --- a/src/windows/WindowsNativeLoader.test.ts +++ b/src/windows/WindowsNativeLoader.test.ts @@ -7,7 +7,9 @@ import { type WindowsNativeLoadHost, } from "./WindowsNativeLoader.js"; -const fixture = (protocolStdin?: boolean) => { +const fixture = ( + capabilities: { protocolStdin?: boolean; jobMemoryLimit?: unknown } = {}, +) => { const bytes = Buffer.alloc(128); bytes.write("MZ"); bytes.writeUInt32LE(64, 0x3c); @@ -51,7 +53,7 @@ const fixture = (protocolStdin?: boolean) => { privateDacl: true, atomicJobAssignment: true, killOnOwnerClose: true, - ...(protocolStdin === undefined ? {} : { protocolStdin }), + ...capabilities, }), }; }, @@ -63,7 +65,7 @@ describe("packaged Windows native admission", () => { it.each([true, false])( "preserves protocol stdin capability %s alongside existing job controls", (protocolStdin) => { - const value = fixture(protocolStdin); + const value = fixture({ protocolStdin }); expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ available: true, authority: { @@ -76,6 +78,22 @@ describe("packaged Windows native admission", () => { }); }, ); + it("admits the Job Object memory limit control only as an affirmative observation", () => { + expect( + loadWindowsNativeAuthority(fixture({ jobMemoryLimit: true }).host), + ).toMatchObject({ + available: true, + authority: { inspection: { jobMemoryLimit: true } }, + }); + // Earlier artifacts omit the control; callers must refuse a limit then. + const earlier = loadWindowsNativeAuthority(fixture().host); + if (!earlier.available) throw new Error(earlier.reason); + expect(earlier.authority.inspection.jobMemoryLimit).toBeUndefined(); + expect( + loadWindowsNativeAuthority(fixture({ jobMemoryLimit: false }).host) + .available, + ).toBe(false); + }); it("loads only after compatibility and digest checks, preserving producer identity", () => { const value = fixture(); expect(loadWindowsNativeAuthority(value.host)).toMatchObject({ diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index 95e98cb8a4..ba58359774 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -44,6 +44,8 @@ const inspectionSchema = z.strictObject({ atomicJobAssignment: z.literal(true), killOnOwnerClose: z.literal(true), protocolStdin: z.boolean().optional(), + /** Absent from artifacts built before owned Job Object commit limits. */ + jobMemoryLimit: z.literal(true).optional(), }); type NativeCall = ( diff --git a/src/windows/WindowsOwnedProcess.test.ts b/src/windows/WindowsOwnedProcess.test.ts index d4fff6ef82..e9cf106fd4 100644 --- a/src/windows/WindowsOwnedProcess.test.ts +++ b/src/windows/WindowsOwnedProcess.test.ts @@ -1,6 +1,11 @@ +import { once } from "node:events"; import { describe, expect, it } from "vitest"; -import { quoteWindowsProcessArgument } from "./WindowsOwnedProcess.js"; +import type { WindowsNativeAuthority } from "./WindowsNativeLoader.js"; +import { + quoteWindowsProcessArgument, + WindowsOwnedProcess, +} from "./WindowsOwnedProcess.js"; describe("Windows process argument representation", () => { it.each([ @@ -18,3 +23,158 @@ describe("Windows process argument representation", () => { expect(() => quoteWindowsProcessArgument("a\0b")).toThrow("NUL"); }); }); + +const LIMIT = 704 * 1024 * 1024; +const settledPoll = { + stdout: Buffer.alloc(0), + stderr: Buffer.from("allocation failed"), + stdoutEnded: true, + stderrEnded: true, + exitCode: 3, + activeProcesses: 0, +}; + +/** Native authority double that records calls and replays exact poll observations. */ +const authority = ( + options: { + readonly jobMemoryLimit?: true; + readonly polls?: readonly unknown[]; + readonly appliedLimit?: (requested: unknown) => unknown; + } = {}, +) => { + const calls: { operation: string; arguments_: readonly unknown[] }[] = []; + const polls = [...(options.polls ?? [])]; + const native: WindowsNativeAuthority = { + inspection: { + abiVersion: 1, + nodeApiVersion: 8, + architecture: "x64", + filesystem: { + requestedPath: "C:\\probe", + finalPath: "C:\\probe", + filesystem: "NTFS", + volumeSerial: "0".repeat(16), + fileId: "0".repeat(32), + size: 0, + directory: true, + }, + privateDacl: true, + atomicJobAssignment: true, + killOnOwnerClose: true, + protocolStdin: true, + ...(options.jobMemoryLimit === undefined ? {} : { jobMemoryLimit: true }), + }, + call: (operation, arguments_) => { + calls.push({ operation, arguments_ }); + if (operation === "process_spawn") + return { + pid: 42, + handle: {}, + jobAssigned: true, + killOnOwnerClose: true, + ...(arguments_.length === 6 + ? { + jobMemoryLimitBytes: ( + options.appliedLimit ?? ((value) => value) + )(arguments_[5]), + } + : {}), + }; + if (operation === "process_poll") return polls.shift(); + if (operation === "process_close") return null; + throw new Error(`Unmodeled native operation ${operation}`); + }, + }; + return { native, calls }; +}; + +const launch = ( + native: WindowsNativeAuthority, + memoryLimitBytes?: number, +): WindowsOwnedProcess => + new WindowsOwnedProcess( + "C:\\runtime\\bun.exe", + ["worker.js"], + "C:\\work", + {}, + false, + { + authority: native, + ...(memoryLimitBytes === undefined ? {} : { memoryLimitBytes }), + }, + ); + +describe("Windows Job Object memory limits", () => { + it("refuses a requested limit before launch when the artifact lacks the control", () => { + const { native, calls } = authority(); + expect(() => launch(native, LIMIT)).toThrow( + "does not support Job Object memory limits", + ); + expect(calls).toEqual([]); + }); + + it("keeps the unlimited launch shape and rejects limit fields it did not request", async () => { + const { native, calls } = authority({ + jobMemoryLimit: true, + polls: [ + { ...settledPoll, memoryLimitReached: true, peakJobMemoryBytes: 1 }, + ], + }); + const child = launch(native); + const failure = once(child, "error"); + expect(calls[0]?.arguments_).toHaveLength(4); + const [error] = await failure; + expect(error).toBeInstanceOf(Error); + expect(child.memoryLimitReached).toBe(false); + expect(child.peakJobMemoryBytes).toBeUndefined(); + }); + + it("passes the limit, verifies its read-back and reports a reached limit before exit", async () => { + const { native, calls } = authority({ + jobMemoryLimit: true, + polls: [ + { + ...settledPoll, + exitCode: null, + stdoutEnded: false, + stderrEnded: false, + activeProcesses: 1, + memoryLimitReached: false, + peakJobMemoryBytes: 300 * 1024 * 1024, + }, + { ...settledPoll, memoryLimitReached: true, peakJobMemoryBytes: LIMIT }, + ], + }); + const child = launch(native, LIMIT); + expect(calls[0]?.arguments_.slice(4)).toEqual([false, LIMIT]); + const closed = once(child, "close"); + const exited = new Promise((resolve) => { + child.once("exit", (code: number) => + resolve({ code, reached: child.memoryLimitReached }), + ); + }); + expect(await exited).toEqual({ code: 3, reached: true }); + expect(child.peakJobMemoryBytes).toBe(LIMIT); + await closed; + expect(calls.at(-1)?.operation).toBe("process_close"); + }); + + it("rejects a limited poll that omits the job's notification state", async () => { + const { native } = authority({ + jobMemoryLimit: true, + polls: [settledPoll], + }); + const child = launch(native, LIMIT); + const [error] = await once(child, "error"); + expect(error).toBeInstanceOf(Error); + expect(child.memoryLimitReached).toBe(false); + }); + + it("refuses a launch whose applied limit differs from the request", () => { + const { native } = authority({ + jobMemoryLimit: true, + appliedLimit: () => LIMIT - 4096, + }); + expect(() => launch(native, LIMIT)).toThrow(); + }); +}); diff --git a/src/windows/WindowsOwnedProcess.ts b/src/windows/WindowsOwnedProcess.ts index 588eafa30b..1b96db867f 100644 --- a/src/windows/WindowsOwnedProcess.ts +++ b/src/windows/WindowsOwnedProcess.ts @@ -24,6 +24,26 @@ const pollSchema = z.strictObject({ exitCode: z.number().int().nonnegative().max(0xffff_ffff).nullable(), activeProcesses: z.number().int().nonnegative(), }); +// A commit-limited job reports its applied limit at launch and its +// notification state on every poll; an unlimited job reports neither. +const limitedPollSchema = pollSchema.extend({ + memoryLimitReached: z.boolean(), + peakJobMemoryBytes: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER), +}); + +/** Optional native job controls for one owned Windows process tree. */ +export interface WindowsOwnedProcessOptions { + /** Open a writable protocol pipe; otherwise stdin is NUL. */ + readonly protocolStdin?: boolean; + /** Job Object commit limit for the whole process tree, in bytes. */ + readonly memoryLimitBytes?: number; + /** Verified native authority; defaults to the package-owned artifact. */ + readonly authority?: WindowsNativeAuthority; +} /** Encode one ordinary Windows argv token using the CRT backslash rules. */ export const quoteWindowsProcessArgument = (value: string): string => { @@ -48,6 +68,9 @@ export class WindowsOwnedProcess extends EventEmitter { #failure: string | undefined; #timer: NodeJS.Timeout | undefined; #cleanup: Promise | undefined; + readonly #memoryLimitBytes: number | undefined; + #memoryLimitReached = false; + #peakJobMemoryBytes: number | undefined; constructor( command: string, @@ -55,14 +78,25 @@ export class WindowsOwnedProcess extends EventEmitter { cwd: string | undefined, environment: NodeJS.ProcessEnv, verbatim: boolean, - protocolStdin = false, + options: WindowsOwnedProcessOptions = {}, ) { super(); - this.#authority = requireWindowsNativeAuthority(); + const protocolStdin = options.protocolStdin === true; + this.#memoryLimitBytes = options.memoryLimitBytes; + this.#authority = options.authority ?? requireWindowsNativeAuthority(); if (protocolStdin && this.#authority.inspection.protocolStdin !== true) throw new Error( "The bundled Windows native artifact does not support owned protocol stdin; use a matching current Windows x64 REA package.", ); + // A requested limit is never dropped: an artifact without the control + // refuses the launch instead of starting an unlimited process. + if ( + this.#memoryLimitBytes !== undefined && + this.#authority.inspection.jobMemoryLimit !== true + ) + throw new Error( + "The bundled Windows native artifact does not support Job Object memory limits; use a matching current Windows x64 REA package.", + ); const entries = Object.entries(environment) .filter((entry): entry is [string, string] => entry[1] !== undefined) .map(([key, value]) => `${key}=${value}`); @@ -74,15 +108,24 @@ export class WindowsOwnedProcess extends EventEmitter { verbatim ? value : quoteWindowsProcessArgument(value), ), ].join(" "); - const launched = launchSchema.parse( - this.#authority.call("process_spawn", [ - command, - line, - cwd ?? "", - entries, - ...(protocolStdin ? [true] : []), - ]), - ); + // Earlier artifacts accept only 4 or 5 arguments; keep that shape unless + // a commit limit is requested. + let controls: unknown[] = protocolStdin ? [true] : []; + if (this.#memoryLimitBytes !== undefined) + controls = [protocolStdin, this.#memoryLimitBytes]; + const spawned = this.#authority.call("process_spawn", [ + command, + line, + cwd ?? "", + entries, + ...controls, + ]); + const launched = + this.#memoryLimitBytes === undefined + ? launchSchema.parse(spawned) + : launchSchema + .extend({ jobMemoryLimitBytes: z.literal(this.#memoryLimitBytes) }) + .parse(spawned); this.pid = launched.pid; this.#handle = launched.handle; this.stdin = protocolStdin @@ -125,6 +168,16 @@ export class WindowsOwnedProcess extends EventEmitter { this.#schedule(); } + /** Whether the job reported reaching its commit limit; sticky once observed. */ + get memoryLimitReached(): boolean { + return this.#memoryLimitReached; + } + + /** Latest observed peak job commit; undefined for an unlimited job. */ + get peakJobMemoryBytes(): number | undefined { + return this.#peakJobMemoryBytes; + } + async #writeInput(chunk: unknown): Promise { const bytes = z.instanceof(Buffer).parse(chunk); const written = z @@ -208,9 +261,19 @@ export class WindowsOwnedProcess extends EventEmitter { this.#timer = undefined; if (this.#closed) return; try { - const state = pollSchema.parse( - this.#authority.call("process_poll", [this.#handle]), - ); + const observed: unknown = this.#authority.call("process_poll", [ + this.#handle, + ]); + let state: z.output; + if (this.#memoryLimitBytes === undefined) + state = pollSchema.parse(observed); + else { + const limited = limitedPollSchema.parse(observed); + // Recorded before exit is emitted, so exit listeners observe it. + this.#memoryLimitReached ||= limited.memoryLimitReached; + this.#peakJobMemoryBytes = limited.peakJobMemoryBytes; + state = limited; + } this.#failure = undefined; if (state.stdout.length > 0) this.stdout.write(state.stdout); if (state.stderr.length > 0) this.stderr.write(state.stderr); diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts index 87e509e652..d4c56d6449 100644 --- a/tests/acceptance/tools/historicalCaptureResources.test.ts +++ b/tests/acceptance/tools/historicalCaptureResources.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { expect, it } from "vitest"; +import { HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../src/browser/history/CaptureRelease.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -42,80 +43,99 @@ const entry = JSON.stringify({ const capture = (count: number) => `{"log":{"version":"1.2","creator":{"name":"synthetic","version":"1"},"entries":[${Array(count).fill(entry).join(",")}]}}`; -it("reports real HAR heap exhaustion through CLI and MCP and preserves server usability", async () => { - const root = await createTestTempDirectory("rea-har-capacity-"); - const path = join(root, "caller-marked.har"); - const dense = capture(34_098); - expect(Buffer.byteLength(dense)).toBeLessThan( - WEB_NETWORK_CAPTURE_LIMITS.inputBytes, - ); - await writeFile(path, dense); - const { call, client } = await connectLocalToolsMcp(); - const response = await call("inspect_web_network_capture", { - capture_path: path, - format: "har", - sensitive_values: ["caller-marked"], - }); - expect(response.isError).toBe(true); - expect(parseMcpToolError(response)).toMatchObject({ - error: { - code: "resource_constraint", - retryable: false, - message: expect.stringContaining("fixed V8 heap"), - remediation: { - action: expect.stringContaining( - "smaller capture exported by its producer", +// Node.js enforces the decoder's V8 heap flags. Bun ignores them; on Windows +// the decoder's Job Object commit limit is the enforced bound instead, and +// Bun elsewhere has no bound this capture can reach. +const jobLimit = HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES; +it.skipIf(process.versions.bun !== undefined && jobLimit === null)( + "reports real HAR memory exhaustion through CLI and MCP and preserves server usability", + async () => { + const root = await createTestTempDirectory("rea-har-capacity-"); + const path = join(root, "caller-marked.har"); + const dense = capture(34_098); + expect(Buffer.byteLength(dense)).toBeLessThan( + WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + ); + await writeFile(path, dense); + const { call, client } = await connectLocalToolsMcp(); + const response = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + sensitive_values: ["caller-marked"], + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "resource_constraint", + retryable: false, + message: expect.stringContaining( + jobLimit === null + ? "fixed V8 heap" + : "Windows Job Object memory limit", ), - }, - details: { - resource: "memory", - reported_limits: { - old_generation_heap_mib: 192, - observed_exit_code: process.platform === "win32" ? 134 : null, - observed_signal: process.platform === "win32" ? null : "SIGABRT", + remediation: { + action: expect.stringContaining( + "smaller capture exported by its producer", + ), }, - captured_output: { - stderr: expect.stringContaining("heap out of memory"), - truncated: false, + details: { + resource: "memory", + reported_limits: + jobLimit === null + ? { + old_generation_heap_mib: 192, + observed_exit_code: process.platform === "win32" ? 134 : null, + observed_signal: + process.platform === "win32" ? null : "SIGABRT", + } + : { process_memory_limit_bytes: jobLimit, observed_signal: null }, + captured_output: + jobLimit === null + ? { + stderr: expect.stringContaining("heap out of memory"), + truncated: false, + } + : { truncated: false }, }, }, - }, - }); - expect(JSON.stringify(response)).not.toContain("caller-marked"); - expect(JSON.stringify(response)).not.toContain("rea doctor"); - const failed: unknown = await execute(process.execPath, [ - cli, - "inspect-web-network-capture", - path, - "har", - "--json", - ]).then( - () => undefined, - (cause: unknown) => cause, - ); - expect(failed).toMatchObject({ code: 1 }); - const stdout = - typeof failed === "object" && failed !== null && "stdout" in failed - ? String(failed.stdout) - : ""; - expect(JSON.parse(stdout)).toMatchObject({ code: "resource_constraint" }); - const small = join(root, "small.har"); - await writeFile(small, capture(2)); - await client.ping(); - const control = await call("inspect_web_network_capture", { - capture_path: small, - format: "har", - }); - expect(control.isError, JSON.stringify(control)).not.toBe(true); - expect(control.structuredContent).toMatchObject({ - normalized_result: { total_records: 2 }, - }); - await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1); - const oversized = await call("inspect_web_network_capture", { - capture_path: path, - format: "har", - }); - expect(parseMcpToolError(oversized)).toMatchObject({ - error: { code: "invalid_request" }, - }); -}, 60_000); + }); + expect(JSON.stringify(response)).not.toContain("caller-marked"); + expect(JSON.stringify(response)).not.toContain("rea doctor"); + const failed: unknown = await execute(process.execPath, [ + cli, + "inspect-web-network-capture", + path, + "har", + "--json", + ]).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failed).toMatchObject({ code: 1 }); + const stdout = + typeof failed === "object" && failed !== null && "stdout" in failed + ? String(failed.stdout) + : ""; + expect(JSON.parse(stdout)).toMatchObject({ code: "resource_constraint" }); + const small = join(root, "small.har"); + await writeFile(small, capture(2)); + await client.ping(); + const control = await call("inspect_web_network_capture", { + capture_path: small, + format: "har", + }); + expect(control.isError, JSON.stringify(control)).not.toBe(true); + expect(control.structuredContent).toMatchObject({ + normalized_result: { total_records: 2 }, + }); + await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1); + const oversized = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + }); + expect(parseMcpToolError(oversized)).toMatchObject({ + error: { code: "invalid_request" }, + }); + }, + 60_000, +); diff --git a/tests/acceptance/webSourceLocationCli.test.ts b/tests/acceptance/webSourceLocationCli.test.ts index 321d5d94ad..bc063270c7 100644 --- a/tests/acceptance/webSourceLocationCli.test.ts +++ b/tests/acceptance/webSourceLocationCli.test.ts @@ -48,7 +48,9 @@ cliTest( generated_offset: 0, execution: "unknown", matches: [{ content: { text: "original" } }], - runtime: { id: "node-v8" }, + runtime: { + id: process.versions.bun === undefined ? "node-v8" : "bun", + }, }, }); const transport = new StdioClientTransport({ diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 41c71638e1..0e41a11bbb 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -5,6 +5,7 @@ import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership. import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; const source = (path: string, value: string): JavaScriptArtifactFile => ({ @@ -18,6 +19,9 @@ const source = (path: string, value: string): JavaScriptArtifactFile => ({ text: { included: true, value }, }); +/** The Job Object limit the worker requests for its 128 MiB heap budget, if any. */ +const processLimit = javaScriptWorkerMemoryLimitBytes(128); + it("reuses one real owned analyzer while retaining distinct source identities and exported returns", async () => { const launches: number[] = []; const worker = new JavaScriptAnalysisWorker( @@ -146,3 +150,131 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a await next.close(); } }); + +// The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit. +const resourceFailureWorker = (heapLimit: string) => + new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) => + spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `const heap = ${heapLimit}; +process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n"); +require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => { + const { id } = JSON.parse(line); + process.stdout.write(JSON.stringify({ kind: "failure", id, reason: "resource", resource: "memory", limits: null, message: "fixture" }) + "\\n"); +});`, + ], + }), + ); + +it("reports the worker heap limit only when the runtime enforces V8 heap flags", async () => { + const worker = resourceFailureWorker( + 'process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null', + ); + try { + const result = await worker.analyze( + source("app.mjs", "export const observed = 7;"), + 100, + ); + if (result.ok) throw new Error("expected a reported resource failure"); + expect(result.error.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: + process.versions.bun === undefined + ? { + worker_old_space_mb: 128, + worker_heap_limit_bytes: expect.any(Number), + worker_timeout_ms: 30_000, + } + : { + worker_heap_limit_bytes: null, + ...(processLimit === null + ? {} + : { worker_process_memory_limit_bytes: processLimit }), + worker_timeout_ms: 30_000, + }, + }); + } finally { + await worker.close(); + } +}); + +it("rejects a worker heap report that contradicts the launching runtime", async () => { + const worker = resourceFailureWorker( + "process.versions.bun === undefined ? null : 1", + ); + try { + const result = await worker.analyze( + source("app.mjs", "export const observed = 7;"), + 100, + ); + if (result.ok) throw new Error("expected a handshake failure"); + expect(result.error.error._tag).toBe("AnalysisOutputError"); + } finally { + await worker.close(); + } +}); + +// The launcher runs a real child without a job limit, so any artifact can run +// it, and its handle reports the notification an owned Windows job records +// before exit. Bun exits 3 (panic) or 1 (exception) at a reached job limit. +it.each([true, false])( + "classifies a worker exit by the job memory notification (reached: %s)", + async (reached) => { + const worker = new JavaScriptAnalysisWorker( + { heapMb: 128, timeoutMs: 30_000 }, + async ({ memoryLimitBytes, ...request }) => { + expect(memoryLimitBytes).toBe(processLimit ?? undefined); + const spawned = await spawnOwnedProviderProcess({ + ...request, + arguments: [ + "-e", + `const heap = process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null; +process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n"); +require("node:readline").createInterface({ input: process.stdin }).once("line", () => { + process.stderr.write("panic: out of memory"); + process.exit(3); +});`, + ], + }); + let exited = false; + spawned.process.once("exit", () => { + exited = true; + }); + Object.defineProperty(spawned.process, "memoryLimitReached", { + get: () => reached && exited, + }); + return spawned; + }, + ); + try { + const result = await worker.analyze( + source("app.mjs", "export const observed = 7;"), + 100, + ); + if (result.ok) throw new Error("expected the worker to fail"); + expect(result.error.error).toMatchObject( + reached + ? { + _tag: "AnalysisResourceConstraintError", + resource: "memory", + reason: expect.stringContaining("process memory limit"), + reportedLimits: + processLimit === null + ? expect.not.objectContaining({ + worker_process_memory_limit_bytes: expect.anything(), + }) + : { worker_process_memory_limit_bytes: processLimit }, + capturedOutput: { stderr: "panic: out of memory" }, + } + : { + _tag: "ProviderAdapterError", + diagnostics: { exit_code: 3 }, + }, + ); + } finally { + await worker.close(); + } + }, +); diff --git a/tests/boundary/javascript/sourceMapDecoder.test.ts b/tests/boundary/javascript/sourceMapDecoder.test.ts index cca8893078..974fa33a34 100644 --- a/tests/boundary/javascript/sourceMapDecoder.test.ts +++ b/tests/boundary/javascript/sourceMapDecoder.test.ts @@ -2,7 +2,10 @@ import { fileURLToPath } from "node:url"; import { access } from "node:fs/promises"; import { expect, it, vi } from "vitest"; import { SourceMapDecoder } from "../../../src/javascript/sourceMaps/SourceMapDecoder.js"; -import type { SourceMapDecoderDependencies } from "../../../src/javascript/sourceMaps/SourceMapCommand.js"; +import { + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + type SourceMapDecoderDependencies, +} from "../../../src/javascript/sourceMaps/SourceMapCommand.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; @@ -85,12 +88,25 @@ it("runs the real pinned codec with an independent heap and removes its private if (!response.ok) throw response.error; expect(response.value).toMatchObject({ engine: { id: "jridgewell-trace-mapping", version: "0.3.31" }, - runtime: { id: "node-v8" }, matches: [{ content: { text: "original" } }], }); - expect(response.value.runtime.v8_heap_limit_bytes).toBeLessThanOrEqual( - 256 * 1024 * 1024, - ); + if (process.versions.bun === undefined) { + expect(response.value.runtime).toMatchObject({ + id: "node-v8", + version: process.version, + }); + expect(response.value.runtime.v8_heap_limit_bytes).toBeLessThanOrEqual( + 256 * 1024 * 1024, + ); + } else + expect(response.value.runtime).toEqual({ + id: "bun", + version: process.versions.bun, + node_compatibility_version: process.versions.node, + v8_heap_limit_bytes: null, + // Windows enforces a Job Object commit limit in place of the heap flags. + process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + }); expect(environment.NODE_OPTIONS).toBe( "--require=/rea-file-that-does-not-exist", ); @@ -213,6 +229,95 @@ it("rejects a malformed actual subprocess reply", async () => { if (response.ok) throw new Error("expected malformed reply"); expect(response.error._tag).toBe("AnalysisOutputError"); }); +it("rejects a codec report from a runtime other than the launching one", async () => { + const actual = await realDecoder().trace(input); + if (!actual.ok) throw actual.error; + const replay = (report: unknown) => + realDecoder({ + launcher: (options) => + spawnOwnedProviderProcess({ + ...options, + arguments: [ + "-e", + `process.stdout.write(${JSON.stringify(JSON.stringify({ state: "success", report }))})`, + ], + }), + }).trace(input); + const unchanged = await replay(actual.value); + if (!unchanged.ok) throw unchanged.error; + expect(unchanged.value).toEqual(actual.value); + const response = await replay({ + ...actual.value, + runtime: + actual.value.runtime.id === "bun" + ? { id: "node-v8", version: "v24.18.0", v8_heap_limit_bytes: 1 } + : { + id: "bun", + version: "1.4.2", + node_compatibility_version: "24.18.0", + v8_heap_limit_bytes: null, + process_memory_limit_bytes: null, + }, + }); + if (response.ok) throw new Error("expected runtime mismatch"); + expect(response.error._tag).toBe("AnalysisOutputError"); + if (actual.value.runtime.id !== "bun") return; + // A Bun codec must report exactly the process limit its launcher enforced. + const limit = await replay({ + ...actual.value, + runtime: { + ...actual.value.runtime, + process_memory_limit_bytes: + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES === null ? 4096 : null, + }, + }); + if (limit.ok) throw new Error("expected process limit mismatch"); + expect(limit.error.message).toContain("process memory limit"); +}); +it.each([true, false])( + "classifies a codec exit by the job memory notification (reached: %s)", + async (reached) => { + const response = await realDecoder({ + launcher: async ({ memoryLimitBytes, ...options }) => { + expect(memoryLimitBytes).toBe( + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES ?? undefined, + ); + // Without a job limit any artifact runs the child; its handle reports + // the notification an owned Windows job records before exit. + const spawned = await spawnOwnedProviderProcess({ + ...options, + arguments: [ + "-e", + 'process.stderr.write("panic: out of memory"); process.exit(3)', + ], + }); + let exited = false; + spawned.process.once("exit", () => { + exited = true; + }); + Object.defineProperty(spawned.process, "memoryLimitReached", { + get: () => reached && exited, + }); + return spawned; + }, + }).trace(input); + if (response.ok) throw new Error("expected codec failure"); + expect(response.error).toMatchObject( + reached + ? { + _tag: "AnalysisResourceConstraintError", + resource: "memory", + reason: expect.stringContaining("process memory limit"), + reportedLimits: { + codec_process_memory_limit_bytes: + SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, + }, + capturedOutput: { stderr: "panic: out of memory" }, + } + : { diagnostics: { exit_code: 3, stderr: "panic: out of memory" } }, + ); + }, +); it("removes the private request when launching the codec fails", async () => { let path: string | undefined; const response = await realDecoder({ diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index bdc75aea88..3874be079f 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -384,6 +384,20 @@ describe("provider process host injection", () => { }); }); +describe("provider process memory limits", () => { + it("refuses a memory limit that the selected launcher cannot enforce", async () => { + await expect( + spawnOwnedProviderProcess({ + command: `${tmpdir()}/rea-provider-must-not-spawn-without-limit`, + arguments: [], + runId: "provider-process-unenforceable-limit-run", + platform: "linux", + memoryLimitBytes: 704 * 1024 * 1024, + }), + ).rejects.toThrow("require the Windows Job Object launcher"); + }); +}); + describe("provider startup cancellation", () => { it("rejects an already cancelled launch before preparing or spawning", async () => { const controller = new AbortController();