| … | … | @@ -77,6 +77,34 @@ function safeDirSize (dir) { |
| 77 | 77 | return total |
| 78 | 78 | } |
| 79 | 79 | |
| 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. | |
| 91 | export 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 | ||
| 80 | 108 | export class Sidecar { |
| 81 | 109 | constructor (opts = {}) { |
| 82 | 110 | this.root = opts.root || DEFAULT_ROOT |
| … | … | @@ -883,16 +911,10 @@ export class Sidecar { |
| 883 | 911 | if (!fs.existsSync(reposDir)) return |
| 884 | 912 | const selfHex = this._identity ? b4a.toString(this._identity.publicKey, 'hex') : null |
| 885 | 913 | |
| 886 | const git = (repo, args) => new Promise((resolve, reject) => { | |
| 914 | const git = (repo, args) => { | |
| 887 | 915 | 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 | } | |
| 896 | 918 | const readOverlayJson = async (repo) => { |
| 897 | 919 | try { return JSON.parse(await git(repo, ['show', 'HEAD:.pear-git/overlay.json'])) } catch { return null } |
| 898 | 920 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh-sidecar", |
| 3 | "version": "0.2.46", | |
| 3 | "version": "0.2.47", | |
| 4 | 4 | "description": "Small optional user-level daemon for pgh — shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/index.js", |
| … | … | @@ -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 | ||
| 17 | import test from 'brittle' | |
| 18 | import fs from '#node:fs' | |
| 19 | import process from '#node:process' | |
| 20 | import { spawn } from '#node:child_process' | |
| 21 | import { spawnCaptureNoLeak } from '../lib/index.js' | |
| 22 | ||
| 23 | const fdCount = () => fs.readdirSync('/proc/' + process.pid + '/fd').length | |
| 24 | ||
| 25 | test('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 | ||
| 46 | test('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 | }) |