From d677d770b7ccf8565a5be172c8d45178d8bdc358 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Fri, 17 Jul 2026 09:49:03 +0200 Subject: [PATCH 1/2] fix: detect heap OOM at production-sized memory limits V8 only traps (SIGTRAP) when the heap limit is too small to boot, which is why `memory: 1` was detected. Every realistic limit exhausts the heap through the normal OOM path and exits with SIGABRT, so it escaped as a raw ChildProcessError and never surfaced as MemoryError. The existing test only covered `memory: 1`, the single value that traps, so it passed while every production limit was broken. Gate the abort on V8's own "out of memory" message so an unrelated abort is not mislabelled. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CTJQQb6p1UWdkireDjCVri --- src/index.js | 8 +++++++- test/error.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index cda0d03..aa9fdbe 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,12 @@ const createError = ({ name, message, ...props }) => { return error } +/* V8 aborts (SIGABRT) on heap exhaustion for any realistic --max-old-space-size, + and only traps (SIGTRAP) when the limit is too small to boot the heap. Gate the + abort on V8's message so an unrelated abort is not reported as a memory error. */ +const isOutOfMemory = ({ signalCode, stderr }) => + signalCode === 'SIGTRAP' || (signalCode === 'SIGABRT' && /out of memory/i.test(stderr ?? '')) + const [nodeMajor] = process.version.slice(1).split('.').map(Number) const PERMISSION_FLAG = nodeMajor >= 24 ? '--permission' : '--experimental-permission' @@ -94,7 +100,7 @@ module.exports = ({ tmpdir, nodePaths } = {}) => { debug.error(serializeError(error)) const profiling = { phases: { total: total() } } - if (error.signalCode === 'SIGTRAP') { + if (isOutOfMemory(error)) { throw createError({ name: 'MemoryError', message: 'Out of memory', diff --git a/test/error.js b/test/error.js index c3554b9..74a8c24 100644 --- a/test/error.js +++ b/test/error.js @@ -111,6 +111,34 @@ test('handle OOM', async t => { t.is(typeof error.profiling.phases.total, 'number') }) +/* `memory: 1` is the only limit small enough that V8 traps instead of aborting. + Every production-sized limit exhausts the heap through the normal OOM path, + which exits with SIGABRT. Growing the heap (not off-heap buffers) is what the + limit actually bounds, so this is the shape of a real customer OOM. */ +test('handle OOM at a production-sized limit', async t => { + const fn = isolatedFunction( + () => { + const storage = [] + while (true) storage.push('x'.repeat(1024)) + }, + { memory: 16 } + ) + + const error = await t.throwsAsync(fn()) + + t.is(error.name, 'MemoryError') + t.is(error.message, 'Out of memory') + t.is(typeof error.profiling.phases.total, 'number') +}) + +test('an abort that is not OOM is not reported as MemoryError', async t => { + const fn = isolatedFunction(() => process.abort(), { memory: 16 }) + + const error = await t.throwsAsync(fn()) + + t.not(error.name, 'MemoryError') +}) + test('handle filesystem permissions', async t => { { const fn = isolatedFunction(() => { From 8f002539e63ea95ae2bb6c1a126a5c10c2cd0172 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Fri, 17 Jul 2026 09:49:19 +0200 Subject: [PATCH 2/2] feat!: report memory as a detailed breakdown profiling.memory was a single RSS sample taken when the function returned. No single number is correct: RSS over-reports (a no-op function reported ~43MB of Node.js baseline) and under-reports (300MB of untouched typed arrays stayed invisible, because pages that are never written to never become resident). Report the fields that answer different questions instead: total RSS of the isolate process, baseline included used total minus the pre-execution baseline heap V8 heap, the only field the `memory` limit bounds external off-heap Buffer/ArrayBuffer, which the limit misses Also correct the README resource-limit example, which claimed 78MB of Uint8Array trips `memory: 64`. It does not: typed arrays are off-heap, so it returns cleanly with external=79.7MB. Replaced with on-heap growth, which the limit does bound. BREAKING CHANGE: profiling.memory is an object instead of a number. Callers reading it as a number should read profiling.memory.used for the memory attributable to the function, or .total for the previous RSS value. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CTJQQb6p1UWdkireDjCVri --- README.md | 25 +++++++++++++++---------- src/index.d.ts | 15 +++++++++++++-- src/template/index.js | 4 +++- test/index.js | 13 ++++++++++++- test/index.test-d.ts | 7 ++++++- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ab64be4..125f821 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,12 @@ const { value, profiling } = await fn() console.log(profiling) // { // cpu: 42.5, -// memory: 128204800, +// memory: { +// total: 128204800, +// used: 85198848, +// heap: 2831776, +// external: 83961856 +// }, // phases: { // compile: 0, // spawn: 48, @@ -242,7 +247,11 @@ console.log(profiling) Each execution includes profiling data: - **cpu** — CPU time (user + system) consumed by the process, in milliseconds. -- **memory** — Peak RSS (Resident Set Size) of the process, in bytes. +- **memory** — Memory breakdown, in bytes, sampled when the function returns. No single number describes the memory a function used, so each field answers a different question: + - **total** — Resident set size of the whole isolate process. Includes the ~43MB Node.js runtime baseline, so it is never zero. + - **used** — `total` minus the baseline measured before the function ran. This is the part attributable to the function. + - **heap** — V8 heap in use. This is the only field bounded by the [`memory`](#memory) limit. + - **external** — Off-heap memory (`Buffer`, `ArrayBuffer`, typed arrays). **Not** bounded by the [`memory`](#memory) limit, and invisible to `total` until the pages are written to. - **phases** — Wall-clock time breakdown of each execution stage, in milliseconds: - **compile** — Time waiting for code compilation (dependency detection, package install, esbuild bundling). This is `0` after the first call since the result is cached. - **spawn** — Process creation, Node.js boot, and template setup overhead. @@ -256,20 +265,16 @@ You can limit a **isolated-function** with memory: ```js const fn = isolatedFunction(() => { const storage = [] - const oneMegabyte = 1024 * 1024 - while (storage.length < 78) { - const array = new Uint8Array(oneMegabyte) - for (let ii = 0; ii < oneMegabyte; ii += 4096) { - array[ii] = 1 - } - storage.push(array) - } + while (true) storage.push('x'.repeat(1024)) }, { memory: 64 }) await fn() // => MemoryError: Out of memory ``` +> [!IMPORTANT] +> The `memory` limit bounds the **V8 heap** (`profiling.memory.heap`), which is where ordinary objects and strings live. It does **not** bound off-heap memory (`profiling.memory.external`): `Buffer`, `ArrayBuffer`, and typed arrays are allocated outside the heap and can grow past the limit without raising `MemoryError`. If a function must be held to a total memory budget, enforce it outside the process (for example a cgroup) rather than relying on `memory` alone. + or by execution time: ```js diff --git a/src/index.d.ts b/src/index.d.ts index c058edf..6594df2 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -12,11 +12,22 @@ export interface Phases { total: number } +export interface Memory { + /** Resident set size of the whole isolate process, in bytes */ + total: number + /** Resident memory attributable to the function (total minus the pre-execution baseline), in bytes */ + used: number + /** V8 heap in use, in bytes. This is the only figure bounded by the `memory` limit */ + heap: number + /** Off-heap memory (Buffer, ArrayBuffer, TypedArray), in bytes. Not bounded by the `memory` limit */ + external: number +} + export interface Profiling { /** CPU time (user + system) in milliseconds */ cpu: number - /** Memory usage in bytes */ - memory: number + /** Memory usage breakdown, in bytes */ + memory: Memory /** Bundled code size in bytes */ size: number /** Execution phase durations in milliseconds */ diff --git a/src/template/index.js b/src/template/index.js index 30aa122..e7ed159 100644 --- a/src/template/index.js +++ b/src/template/index.js @@ -4,7 +4,9 @@ const SERIALIZE_ERROR = require('./serialize-error') module.exports = snippet => `;(send => { process.stdout.write = function () {} - const respond = (isFulfilled, value, run, logs = {}) => { const {user, system} = process.cpuUsage(); send(JSON.stringify({isFulfilled, logging: logs, value, profiling: {cpu: (user + system) / 1000, memory: process.memoryUsage().rss, run}})) } + const baseline = process.memoryUsage().rss + const memory = () => { const m = process.memoryUsage(); return {total: m.rss, used: Math.max(0, m.rss - baseline), heap: m.heapUsed, external: m.external} } + const respond = (isFulfilled, value, run, logs = {}) => { const {user, system} = process.cpuUsage(); send(JSON.stringify({isFulfilled, logging: logs, value, profiling: {cpu: (user + system) / 1000, memory: memory(), run}})) } return Promise.resolve().then(async () => { const args = JSON.parse(process.argv[2]) diff --git a/test/index.js b/test/index.js index da3facf..38acb59 100644 --- a/test/index.js +++ b/test/index.js @@ -156,7 +156,18 @@ test('memory profiling', async t => { t.is(value, undefined) t.is(typeof profiling.cpu, 'number') - t.is(typeof profiling.memory, 'number') + t.is(typeof profiling.memory.total, 'number') + t.is(typeof profiling.memory.used, 'number') + t.is(typeof profiling.memory.heap, 'number') + t.is(typeof profiling.memory.external, 'number') + + // the 78MB of touched Uint8Array is off-heap, so it lands in external and + // shows up as memory the function is accountable for, not as heap + t.true(profiling.memory.external > 64 * 1024 * 1024) + t.true(profiling.memory.used > 64 * 1024 * 1024) + t.true(profiling.memory.total >= profiling.memory.used) + t.true(profiling.memory.heap < profiling.memory.external) + t.is(typeof profiling.phases.install, 'number') t.is(typeof profiling.phases.build, 'number') t.is(typeof profiling.phases.spawn, 'number') diff --git a/test/index.test-d.ts b/test/index.test-d.ts index fcd0be2..8a6a5d3 100644 --- a/test/index.test-d.ts +++ b/test/index.test-d.ts @@ -4,6 +4,7 @@ import createIsolatedFunction, { FailureResult, SuccessResult, Profiling, + Memory, Logging, IsolatedFunctionOptions, IsolatedFunctionInstance, @@ -99,7 +100,11 @@ if (execResult.isFulfilled) { const { profiling, logging } = execResult expectType(profiling.cpu) - expectType(profiling.memory) + expectType(profiling.memory) + expectType(profiling.memory.total) + expectType(profiling.memory.used) + expectType(profiling.memory.heap) + expectType(profiling.memory.external) expectType(profiling.phases.compile) expectType(profiling.phases.spawn) expectType(profiling.phases.run)