fix: add temp file reaper to prevent unbounded /tmp accumulation

core.cjs output() created gsd-*.json files in /tmp for large payloads
but never cleaned them up, causing unbounded disk usage (800+ GB
reported in #1251). Similarly, profile-pipeline.cjs created
gsd-pipeline-* and gsd-profile-* temp directories without cleanup.

Adds reapStaleTempFiles() that removes gsd-prefixed temp files/dirs
older than 5 minutes. Called opportunistically before each new temp
file/dir creation. Non-critical — cleanup failures never break output.

Closes #1251

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-03-20 15:46:06 -04:00
parent 3f133fe3bc
commit 1a5259ce16
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) {