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/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/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/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(() => { 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)