Skip to content

Parent OOM on second stdio: 'inherit' child when first spawn task Promise is retained in a coordinator queue #64316

Description

@steve02081504

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

  1. 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).
  2. Avoid stdio: 'inherit' in long-lived orchestrators; use stdio: 'pipe' with bounded streaming instead.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions