OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / compat-exec-no-stdin-leak.test.js

71 lines · javascript ref:
/**
 * 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)`)
})