OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / 7779b42

overlay-mirror: fix git-subprocess stdio fd leak (the real 232k-fd driver)
_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 every spawn leaked ~1 fd (proven: ~1.02/spawn over 60 spawns). The mirror runs ~one git per repo/pointer every 5 min (~447 spawns over ~114 repos on stortron), leaking ~89 fds/min → the always-on sidecar climbed to 232k fds / 15.8 GB in 43h. These are anonymous socketpairs, invisible to the hyper-svc UdsServer idle reaper, so nothing reclaimed them. This was the DOMINANT leak — distinct from the pgh-hello fd-lock poison-pill (0.2.46), which spammed errors but did not spawn subprocesses and so did not leak the fds. The restart merely reset the counter; the mirror climbed it back. Fix: extract spawnCaptureNoLeak(spawn, cmd, args) — listen on 'close' (fires after stdout/stderr EOF, so output is fully captured) and destroy() each stdio stream to release the fds. git() delegates to it. Verified: 60 spawns → fd delta 0 (was +61). Regression test/subprocess-fd-leak.test.js (52/52). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7779b428dcbed68ef9036e2ae04571fe48480c4b
by plankatron <fleeky@gmail.com> · 2026-07-13T19:40:43.000Z

Changed files (3) +84 -10

lib/index.js +31 -9
……@@ -77,6 +77,34 @@ function safeDirSize (dir) {
7777 return total
7878}
7979
80// Spawn a child, capture stdout, and — critically — RELEASE the parent-side
81// stdio socketpair fds when it finishes. bare-subprocess backs child stdio with
82// socketpairs and does NOT auto-close them on 'exit'; resolving on 'exit' (or
83// never destroying the streams) leaks ~1 fd per spawn. The overlay-mirror runs
84// ~one git per repo/pointer every 5 min, so that pattern leaked ~89 fds/min and
85// climbed the always-on sidecar to 232k fds / 15.8 GB in 43h on stortron
86// (2026-07-13). These are anonymous socketpairs — invisible to the hyper-svc
87// UdsServer idle reaper, so nothing reclaims them. We listen on 'close' (fires
88// after stdout/stderr EOF, so output is fully captured) and destroy() each
89// stream to free the fds. `spawn` is injected so tests can pass bare-subprocess
90// directly. Proven by test/subprocess-fd-leak.test.js.
91export function spawnCaptureNoLeak (spawn, cmd, args) {
92 return new Promise((resolve, reject) => {
93 const p = spawn(cmd, args)
94 let out = ''
95 let err = ''
96 p.stdout.on('data', (d) => { out += d })
97 p.stderr.on('data', (d) => { err += d })
98 p.on('error', reject)
99 p.on('close', (code) => {
100 try { p.stdout.destroy() } catch {}
101 try { p.stderr.destroy() } catch {}
102 try { if (p.stdin) p.stdin.destroy() } catch {}
103 code === 0 ? resolve(out) : reject(new Error(((err || '') + '').trim() || (cmd + ' exited ' + code)))
104 })
105 })
106}
107
80108export class Sidecar {
81109 constructor (opts = {}) {
82110 this.root = opts.root || DEFAULT_ROOT
……@@ -883,16 +911,10 @@ export class Sidecar {
883911 if (!fs.existsSync(reposDir)) return
884912 const selfHex = this._identity ? b4a.toString(this._identity.publicKey, 'hex') : null
885913
886 const git = (repo, args) => new Promise((resolve, reject) => {
914 const git = (repo, args) => {
887915 const gd = path.join(reposDir, repo.endsWith('.git') ? repo : repo + '.git')
888 const p = spawn('git', ['--git-dir', gd, ...args])
889 let out = ''
890 let err = ''
891 p.stdout.on('data', (d) => { out += d })
892 p.stderr.on('data', (d) => { err += d })
893 p.on('error', reject)
894 p.on('exit', (code) => code === 0 ? resolve(out) : reject(new Error(((err || '') + '').trim() || ('git exited ' + code))))
895 })
916 return spawnCaptureNoLeak(spawn, 'git', ['--git-dir', gd, ...args])
917 }
896918 const readOverlayJson = async (repo) => {
897919 try { return JSON.parse(await git(repo, ['show', 'HEAD:.pear-git/overlay.json'])) } catch { return null }
898920 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.46",
3 "version": "0.2.47",
44 "description": "Small optional user-level daemon for pgh — shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
test/subprocess-fd-leak.test.js +52 -0
……@@ -0,0 +1,52 @@
1/**
2 * Regression: overlay-mirror git-subprocess stdio fd leak (2026-07-13).
3 *
4 * `_runOverlayMirror`'s git() helper spawned a child, read stdout/stderr, and
5 * resolved on 'exit' WITHOUT closing the parent-side stdio streams. bare-subprocess
6 * backs child stdio with unix socketpairs and does NOT auto-close them on 'exit',
7 * so each spawn leaked ~1 fd. The mirror runs ~one git per repo/pointer every 5
8 * min (~hundreds of spawns), leaking ~89 fds/min → the always-on sidecar climbed
9 * to 232k fds / 15.8 GB in 43h on stortron. These are anonymous socketpairs, so
10 * the hyper-svc UdsServer idle reaper never reclaims them.
11 *
12 * spawnCaptureNoLeak (the shared helper git() now delegates to) listens on 'close'
13 * and destroys the stdio streams. This test proves it does not leak fds under a
14 * burst of spawns.
15 */
16
17import test from 'brittle'
18import fs from '#node:fs'
19import process from '#node:process'
20import { spawn } from '#node:child_process'
21import { spawnCaptureNoLeak } from '../lib/index.js'
22
23const fdCount = () => fs.readdirSync('/proc/' + process.pid + '/fd').length
24
25test('spawnCaptureNoLeak does not leak parent-side stdio fds across many spawns', async (t) => {
26 const N = 50
27 // Warm up once so any one-time module/handle allocation is excluded.
28 await spawnCaptureNoLeak(spawn, 'git', ['--version'])
29 await new Promise((r) => setTimeout(r, 200))
30
31 const before = fdCount()
32 for (let i = 0; i < N; i++) {
33 const out = await spawnCaptureNoLeak(spawn, 'git', ['--version'])
34 t.ok(out.includes('git version'), 'stdout captured (spawn ' + i + ')')
35 }
36 await new Promise((r) => setTimeout(r, 300))
37 const after = fdCount()
38
39 const perSpawn = (after - before) / N
40 t.comment('fd delta over ' + N + ' spawns: ' + (after - before) + ' (~' + perSpawn.toFixed(2) + '/spawn)')
41 // The buggy pattern leaked ~1 fd/spawn (→ ~50 here). Allow a tiny slack for
42 // unrelated runtime allocations but fail well below the leak signature.
43 t.ok(after - before <= 5, 'fd growth stays bounded (no ~1/spawn socketpair leak)')
44})
45
46test('spawnCaptureNoLeak rejects on non-zero exit with stderr', async (t) => {
47 await t.exception(
48 spawnCaptureNoLeak(spawn, 'git', ['rev-parse', '--verify', 'definitely-not-a-ref-xyz']),
49 /./,
50 'non-zero git exit rejects'
51 )
52})