/**
* Regression: async execFile must NOT open a stdin pipe per spawn.
*
* bare-subprocess creates a writable Pipe for every 'pipe' stdio slot, but only
* registers slots i>0 in `_closing` — nothing ever closes stdin. So spawning
* with `stdio: 'pipe'` leaks that pipe on every call. Measured at 84.4 KB per
* spawn, growing linearly with no plateau. In production this showed up as the
* pear-git-v3 daemon growing ~350 MB/h (the 5-minute reconcile sweep fires
* ~480 git spawns), reaching 2.7 GB in under 6 hours.
*
* `spawnSync` is immune — it nulls stdio[0] rather than piping it — which is
* why the older blocking implementation never leaked and this only appeared
* once the git plumbing went async.
*
* The primary assertion here is the INVARIANT (stdin is not a fifo), which is
* deterministic. The growth check is secondary and deliberately loose, since
* RSS under a loaded test runner is noisy.
*/
import test from 'brittle'
import { execFile } from '../lib/_compat-exec.js'
test('execFile does not give the child a piped stdin', async (t) => {
// With stdin 'ignore' the child gets /dev/null — a CHARACTER device. With
// 'pipe' it gets a libuv pipe, which on Unix is an AF_UNIX socketpair.
// Test for the char device, NOT for a fifo: `[ -p ]` is false for a socket
// too, so a fifo check passes even while leaking (verified — it did).
const out = await execFile('sh', ['-c', 'if [ -c /dev/stdin ]; then echo CHARDEV; else echo OTHER; fi'])
t.is(out.toString().trim(), 'CHARDEV', 'child stdin is /dev/null, not a pipe (the leaked handle)')
})
test('execFile still captures stdout and throws on non-zero exit', async (t) => {
const out = await execFile('sh', ['-c', 'echo hello-stdout'])
t.is(out.toString().trim(), 'hello-stdout', 'stdout captured')
await t.exception(
execFile('sh', ['-c', 'echo to-stderr >&2; exit 3']),
'non-zero exit rejects'
)
try {
await execFile('sh', ['-c', 'echo to-stderr >&2; exit 3'])
} catch (err) {
t.is(err.status, 3, 'exit status preserved on the error')
t.ok(err.stderr.toString().includes('to-stderr'), 'stderr captured on the error')
}
})
test('repeated execFile calls do not grow RSS without bound', async (t) => {
const { readFileSync } = await import('#node:fs')
const rss = () => {
const m = readFileSync('/proc/self/status', 'utf8').match(/VmRSS:\s+(\d+)/)
return m ? parseInt(m[1], 10) : -1
}
if (rss() < 0) return t.pass('no /proc — skipped')
const BATCH = 150
const spin = async (n) => { for (let i = 0; i < n; i++) await execFile('sh', ['-c', ':']) }
await spin(BATCH) // warm up allocators first
const afterWarm = rss()
await spin(BATCH) // second identical batch
const afterSecond = rss()
const growth = afterSecond - afterWarm
// Leaking would add ~84 KB x 150 = ~12.6 MB here and keep climbing. Fixed,
// the second batch is roughly flat. 6 MB leaves generous headroom for noise.
t.ok(growth < 6144, `second batch of ${BATCH} spawns grew ${growth} KB (< 6 MB expected)`)
})