Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
8 changes: 7 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/template/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
28 changes: 28 additions & 0 deletions test/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
13 changes: 12 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
7 changes: 6 additions & 1 deletion test/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import createIsolatedFunction, {
FailureResult,
SuccessResult,
Profiling,
Memory,
Logging,
IsolatedFunctionOptions,
IsolatedFunctionInstance,
Expand Down Expand Up @@ -99,7 +100,11 @@ if (execResult.isFulfilled) {
const { profiling, logging } = execResult

expectType<number>(profiling.cpu)
expectType<number>(profiling.memory)
expectType<Memory>(profiling.memory)
expectType<number>(profiling.memory.total)
expectType<number>(profiling.memory.used)
expectType<number>(profiling.memory.heap)
expectType<number>(profiling.memory.external)
expectType<number>(profiling.phases.compile)
expectType<number>(profiling.phases.spawn)
expectType<number>(profiling.phases.run)
Expand Down