Merge pull request #1264 from gsd-build/fix/tmp-file-cleanup-1251

fix: temp file reaper prevents unbounded /tmp growth
This commit is contained in:
Tom Boucher
2026-03-20 15:50:18 -04:00
committed by GitHub
3 changed files with 88 additions and 1 deletions

View File

@@ -112,6 +112,40 @@ function findProjectRoot(startDir) {
// ─── Output helpers ───────────────────────────────────────────────────────────
/**
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
* @param {string} prefix - filename prefix to match (e.g., 'gsd-')
* @param {object} opts
* @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
* @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
*/
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
try {
const tmpDir = require('os').tmpdir();
const now = Date.now();
const entries = fs.readdirSync(tmpDir);
for (const entry of entries) {
if (!entry.startsWith(prefix)) continue;
const fullPath = path.join(tmpDir, entry);
try {
const stat = fs.statSync(fullPath);
if (now - stat.mtimeMs > maxAgeMs) {
if (stat.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else if (!dirsOnly) {
fs.unlinkSync(fullPath);
}
}
} catch {
// File may have been removed between readdir and stat — ignore
}
}
} catch {
// Non-critical — don't let cleanup failures break output
}
}
function output(result, raw, rawValue) {
if (raw && rawValue !== undefined) {
process.stdout.write(String(rawValue));
@@ -120,6 +154,7 @@ function output(result, raw, rawValue) {
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
if (json.length > 50000) {
reapStaleTempFiles();
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
fs.writeFileSync(tmpPath, json, 'utf-8');
process.stdout.write('@file:' + tmpPath);
@@ -1005,6 +1040,7 @@ module.exports = {
withPlanningLock,
findProjectRoot,
detectSubRepos,
reapStaleTempFiles,
MODEL_ALIAS_MAP,
planningDir,
planningPaths,

View File

@@ -12,7 +12,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const { output, error, safeReadFile } = require('./core.cjs');
const { output, error, safeReadFile, reapStaleTempFiles } = require('./core.cjs');
// ─── Session I/O Helpers ──────────────────────────────────────────────────────
@@ -333,6 +333,7 @@ async function cmdExtractMessages(projectArg, options, raw, overridePath) {
sessions = sessions.slice(0, options.limit);
}
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
@@ -511,6 +512,7 @@ async function cmdProfileSample(overridePath, options, raw) {
}
}
reapStaleTempFiles('gsd-profile-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
for (const msg of allMessages) {

View File

@@ -18,6 +18,7 @@ const {
escapeRegex,
generateSlugInternal,
normalizePhaseName,
reapStaleTempFiles,
normalizeMd,
comparePhaseNum,
safeReadFile,
@@ -1315,3 +1316,51 @@ describe('findProjectRoot', () => {
assert.strictEqual(findProjectRoot(backendDir), backendDir);
});
});
// ─── reapStaleTempFiles ─────────────────────────────────────────────────────
describe('reapStaleTempFiles', () => {
test('removes stale gsd-*.json files older than maxAgeMs', () => {
const tmpDir = os.tmpdir();
const stalePath = path.join(tmpDir, `gsd-reap-test-${Date.now()}.json`);
fs.writeFileSync(stalePath, '{}');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(stalePath, oldTime, oldTime);
reapStaleTempFiles('gsd-reap-test-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(stalePath), 'stale file should be removed');
});
test('preserves fresh gsd-*.json files', () => {
const tmpDir = os.tmpdir();
const freshPath = path.join(tmpDir, `gsd-reap-fresh-${Date.now()}.json`);
fs.writeFileSync(freshPath, '{}');
reapStaleTempFiles('gsd-reap-fresh-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(fs.existsSync(freshPath), 'fresh file should be preserved');
// Clean up
fs.unlinkSync(freshPath);
});
test('removes stale temp directories when present', () => {
const tmpDir = os.tmpdir();
const staleDir = fs.mkdtempSync(path.join(tmpDir, 'gsd-reap-dir-'));
fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test');
// Set mtime to 10 minutes ago
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(staleDir, oldTime, oldTime);
reapStaleTempFiles('gsd-reap-dir-', { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(staleDir), 'stale directory should be removed');
});
test('does not throw on empty or missing prefix matches', () => {
assert.doesNotThrow(() => {
reapStaleTempFiles('gsd-nonexistent-prefix-xyz-', { maxAgeMs: 0 });
});
});
});