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:
@@ -112,6 +112,40 @@ function findProjectRoot(startDir) {
|
|||||||
|
|
||||||
// ─── Output helpers ───────────────────────────────────────────────────────────
|
// ─── 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) {
|
function output(result, raw, rawValue) {
|
||||||
if (raw && rawValue !== undefined) {
|
if (raw && rawValue !== undefined) {
|
||||||
process.stdout.write(String(rawValue));
|
process.stdout.write(String(rawValue));
|
||||||
@@ -120,6 +154,7 @@ function output(result, raw, rawValue) {
|
|||||||
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
// 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.
|
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
||||||
if (json.length > 50000) {
|
if (json.length > 50000) {
|
||||||
|
reapStaleTempFiles();
|
||||||
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
|
const tmpPath = path.join(require('os').tmpdir(), `gsd-${Date.now()}.json`);
|
||||||
fs.writeFileSync(tmpPath, json, 'utf-8');
|
fs.writeFileSync(tmpPath, json, 'utf-8');
|
||||||
process.stdout.write('@file:' + tmpPath);
|
process.stdout.write('@file:' + tmpPath);
|
||||||
@@ -1005,6 +1040,7 @@ module.exports = {
|
|||||||
withPlanningLock,
|
withPlanningLock,
|
||||||
findProjectRoot,
|
findProjectRoot,
|
||||||
detectSubRepos,
|
detectSubRepos,
|
||||||
|
reapStaleTempFiles,
|
||||||
MODEL_ALIAS_MAP,
|
MODEL_ALIAS_MAP,
|
||||||
planningDir,
|
planningDir,
|
||||||
planningPaths,
|
planningPaths,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
const { output, error, safeReadFile } = require('./core.cjs');
|
const { output, error, safeReadFile, reapStaleTempFiles } = require('./core.cjs');
|
||||||
|
|
||||||
// ─── Session I/O Helpers ──────────────────────────────────────────────────────
|
// ─── Session I/O Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -333,6 +333,7 @@ async function cmdExtractMessages(projectArg, options, raw, overridePath) {
|
|||||||
sessions = sessions.slice(0, options.limit);
|
sessions = sessions.slice(0, options.limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
|
||||||
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
|
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 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
|
||||||
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
|
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const {
|
|||||||
escapeRegex,
|
escapeRegex,
|
||||||
generateSlugInternal,
|
generateSlugInternal,
|
||||||
normalizePhaseName,
|
normalizePhaseName,
|
||||||
|
reapStaleTempFiles,
|
||||||
normalizeMd,
|
normalizeMd,
|
||||||
comparePhaseNum,
|
comparePhaseNum,
|
||||||
safeReadFile,
|
safeReadFile,
|
||||||
@@ -1315,3 +1316,51 @@ describe('findProjectRoot', () => {
|
|||||||
assert.strictEqual(findProjectRoot(backendDir), backendDir);
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user