Environment
- OS: Windows 11 (win32 10.0.26200)
- Node.js: v23.11.0
- V8: bundled with Node 23
- Heap limit: default
v8.getHeapStatistics().heap_size_limit ≈ 4144 MiB
Summary
When a parent process runs two child processes with inherited stdio inside a coordinator-style scheduler that keeps the first task's Promise alive in a tasks[] array (via Promise.race / Promise.all), the parent heap jumps from ~6 MiB to ~4058 MiB during the second child and dies with:
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
The same two spawns without the coordinator pattern (plain sequential await) stay at ~5–6 MiB.
This reproduces with raw child_process.spawn — not specific to any npm wrapper.
Results (verified 2026-07-06)
| Pattern |
API |
Parent heap after child 0 |
Result |
Sequential await ×2 |
child_process.spawn + stdio: 'inherit' |
~5.7 MiB |
✅ exit 0, heap ~5 MiB |
| Coordinator ×2 (serial gate) |
child_process.spawn + stdio: 'inherit' |
~5.7 MiB before child 1 |
❌ OOM ~100s, heap ~4058 MiB |
Sequential await ×2 |
@steve02081504/exec execFile + inherit |
~5.7 MiB |
✅ exit 0, heap ~5 MiB |
| Coordinator ×2 (serial gate) |
execFile + inherit |
~5.7 MiB before child 1 |
❌ OOM ~105s, heap ~4058 MiB |
Coordinator includes a serial gate (SuiteRunGate(1) semantics) — only one child runs at a time.
Minimal reproduction
import { spawn } from 'node:child_process'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import v8 from 'node:v8'
const node = process.execPath
const childSec = 2
const childSrc = `const sec = Number(process.env.REPRO_CHILD_SEC ?? '${childSec}')
const deadline = Date.now() + sec * 1000
let n = 0
while (Date.now() < deadline) {
console.log('line ' + (n++) + ' ' + 'x'.repeat(80))
await new Promise(r => setTimeout(r, 100))
}
`
const dir = await mkdtemp(join(tmpdir(), 'node-coord-repro-'))
const childPath = join(dir, 'child.mjs')
await writeFile(childPath, childSrc)
function logHeap(label) {
const { used_heap_size, heap_size_limit } = v8.getHeapStatistics()
console.warn(`[repro] ${label}: ${(used_heap_size / 1024 / 1024).toFixed(1)} / ${(heap_size_limit / 1024 / 1024).toFixed(0)} MiB`)
}
function spawnInherit(round) {
logHeap(`before spawn ${round}`)
return new Promise(resolve => {
const child = spawn(node, [childPath, String(round)], {
stdio: 'inherit',
env: { ...process.env, REPRO_CHILD_SEC: String(childSec) },
})
child.on('close', code => {
logHeap(`after spawn ${round}`)
resolve(code ?? 1)
})
})
}
async function runCoordinator(handler) {
const total = 2
const resolved = new Set()
const inFlight = new Set()
const tasks = []
let running = false
const waiters = []
const acquire = () => new Promise(resolve => {
const tryEnter = () => {
if (!running) {
running = true
resolve(() => { running = false; waiters.shift()?.() })
return
}
waiters.push(tryEnter)
}
tryEnter()
})
while (resolved.size < total) {
let scheduled = false
for (let index = 0; index < total; index++) {
if (resolved.has(index) || inFlight.has(index)) continue
scheduled = true
inFlight.add(index)
tasks.push((async () => {
try {
const release = await acquire()
try { await handler(index) }
finally { release() }
}
finally {
inFlight.delete(index)
resolved.add(index)
}
})())
}
if (!scheduled) await Promise.race(tasks)
}
await Promise.all(tasks)
}
logHeap('start')
await runCoordinator(async i => {
if (await spawnInherit(i)) throw new Error('child failed')
})
logHeap('done')
Run
# control — ~6s, heap ~5 MiB
node repro.mjs # with sequential variant
# coordinator — ~100s, OOM exit 134
node repro.mjs # coordinator variant
Observed output (coordinator, REPRO_CHILD_SEC=2)
[repro] after spawn 0: 5.7 / 4144 MiB
[repro] before spawn 1: 5.7 / 4144 MiB
... child 1 prints ~19 lines ...
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
GC logs before crash show heap at 4058–4067 MiB despite only ~19 lines of child output.
Expected behavior
Parent heap should remain O(1) regardless of scheduling pattern. Retaining a resolved spawn Promise in a tasks[] array while starting a second stdio: 'inherit' child should not cause gigabyte-scale allocation in the parent.
Actual behavior
Second inherited-stdio child under coordinator scheduling causes parent heap to grow to the V8 limit (~4 GiB) and OOM. Growth is proportional to child stdout volume (more output → faster OOM).
Notes / hypothesis
- With
stdio: 'inherit', spawn sets child.stdout / child.stderr to null; no pipe listeners in userland.
- First child has already exited before the second starts; parent heap is still ~5.7 MiB at the second spawn boundary.
- The coordinator keeps the first task's
Promise in tasks[] until Promise.all at the end; plain sequential await does not retain both closures simultaneously in the same way.
- Suspect Node retains internal buffers or V8 handles tied to the first
ChildProcess closure while the second inherited-stdio child is active and writing to the shared TTY.
Workarounds
- Do not retain first-spawn task Promises across a second inherited-stdio spawn (use plain sequential
await, or spawn each suite in a fresh parent process).
- Avoid
stdio: 'inherit' in long-lived orchestrators; use stdio: 'pipe' with bounded streaming instead.
- After each spawn resolves, ensure the coordinator does not hold references to the handler closure or ChildProcess (may not be sufficient — runtime leak persists with raw
spawn).
Environment
v8.getHeapStatistics().heap_size_limit≈ 4144 MiBSummary
When a parent process runs two child processes with inherited stdio inside a coordinator-style scheduler that keeps the first task's
Promisealive in atasks[]array (viaPromise.race/Promise.all), the parent heap jumps from ~6 MiB to ~4058 MiB during the second child and dies with:The same two spawns without the coordinator pattern (plain sequential
await) stay at ~5–6 MiB.This reproduces with raw
child_process.spawn— not specific to any npm wrapper.Results (verified 2026-07-06)
await×2child_process.spawn+stdio: 'inherit'child_process.spawn+stdio: 'inherit'await×2@steve02081504/execexecFile+ inheritexecFile+ inheritCoordinator includes a serial gate (
SuiteRunGate(1)semantics) — only one child runs at a time.Minimal reproduction
Run
Observed output (coordinator,
REPRO_CHILD_SEC=2)GC logs before crash show heap at 4058–4067 MiB despite only ~19 lines of child output.
Expected behavior
Parent heap should remain O(1) regardless of scheduling pattern. Retaining a resolved spawn
Promisein atasks[]array while starting a secondstdio: 'inherit'child should not cause gigabyte-scale allocation in the parent.Actual behavior
Second inherited-stdio child under coordinator scheduling causes parent heap to grow to the V8 limit (~4 GiB) and OOM. Growth is proportional to child stdout volume (more output → faster OOM).
Notes / hypothesis
stdio: 'inherit',spawnsetschild.stdout/child.stderrtonull; no pipe listeners in userland.Promiseintasks[]untilPromise.allat the end; plain sequentialawaitdoes not retain both closures simultaneously in the same way.ChildProcessclosure while the second inherited-stdio child is active and writing to the shared TTY.Workarounds
await, or spawn each suite in a fresh parent process).stdio: 'inherit'in long-lived orchestrators; usestdio: 'pipe'with bounded streaming instead.spawn).