OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar / test / subprocess-fd-leak.test.js

53 lines · javascript ref:
/**
 * Regression: overlay-mirror git-subprocess stdio fd leak (2026-07-13).
 *
 * `_runOverlayMirror`'s git() helper spawned a child, read stdout/stderr, and
 * resolved on 'exit' WITHOUT closing the parent-side stdio streams. bare-subprocess
 * backs child stdio with unix socketpairs and does NOT auto-close them on 'exit',
 * so each spawn leaked ~1 fd. The mirror runs ~one git per repo/pointer every 5
 * min (~hundreds of spawns), leaking ~89 fds/min → the always-on sidecar climbed
 * to 232k fds / 15.8 GB in 43h on stortron. These are anonymous socketpairs, so
 * the hyper-svc UdsServer idle reaper never reclaims them.
 *
 * spawnCaptureNoLeak (the shared helper git() now delegates to) listens on 'close'
 * and destroys the stdio streams. This test proves it does not leak fds under a
 * burst of spawns.
 */

import test from 'brittle'
import fs from '#node:fs'
import process from '#node:process'
import { spawn } from '#node:child_process'
import { spawnCaptureNoLeak } from '../lib/index.js'

const fdCount = () => fs.readdirSync('/proc/' + process.pid + '/fd').length

test('spawnCaptureNoLeak does not leak parent-side stdio fds across many spawns', async (t) => {
  const N = 50
  // Warm up once so any one-time module/handle allocation is excluded.
  await spawnCaptureNoLeak(spawn, 'git', ['--version'])
  await new Promise((r) => setTimeout(r, 200))

  const before = fdCount()
  for (let i = 0; i < N; i++) {
    const out = await spawnCaptureNoLeak(spawn, 'git', ['--version'])
    t.ok(out.includes('git version'), 'stdout captured (spawn ' + i + ')')
  }
  await new Promise((r) => setTimeout(r, 300))
  const after = fdCount()

  const perSpawn = (after - before) / N
  t.comment('fd delta over ' + N + ' spawns: ' + (after - before) + ' (~' + perSpawn.toFixed(2) + '/spawn)')
  // The buggy pattern leaked ~1 fd/spawn (→ ~50 here). Allow a tiny slack for
  // unrelated runtime allocations but fail well below the leak signature.
  t.ok(after - before <= 5, 'fd growth stays bounded (no ~1/spawn socketpair leak)')
})

test('spawnCaptureNoLeak rejects on non-zero exit with stderr', async (t) => {
  await t.exception(
    spawnCaptureNoLeak(spawn, 'git', ['rev-parse', '--verify', 'definitely-not-a-ref-xyz']),
    /./,
    'non-zero git exit rejects'
  )
})