Skip to content

Commit 358f701

Browse files
author
Linux2010
committed
fix(sessions): sweep orphaned atomic-write temp files at gateway startup
writeTextAtomic stages into <store>.<pid>.<uuid>.tmp then renames. If the process dies between write and rename (SIGKILL, OOM, hard shutdown), the temp is orphaned and accumulates on disk (#56827). Added sweepOrphanStoreTempsSync() that reclaims stale .tmp files older than 5 minutes (safe because live atomic writes rename within ms). Called once at gateway startup via server-startup-early.ts. Fixes #89520
1 parent c6abc85 commit 358f701

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

src/config/sessions/disk-budget.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
resolveTrajectoryPointerFilePath,
1010
} from "../../trajectory/paths.js";
1111
import { formatSessionArchiveTimestamp } from "./artifacts.js";
12-
import { enforceSessionDiskBudget, pruneUnreferencedSessionArtifacts } from "./disk-budget.js";
12+
import {
13+
enforceSessionDiskBudget,
14+
pruneUnreferencedSessionArtifacts,
15+
sweepOrphanStoreTempsSync,
16+
} from "./disk-budget.js";
1317
import { saveSessionStore } from "./store.js";
1418
import type { SessionEntry } from "./types.js";
1519

@@ -756,3 +760,63 @@ describe("pruneUnreferencedSessionArtifacts", () => {
756760
});
757761
});
758762
});
763+
764+
describe("sweepOrphanStoreTempsSync", () => {
765+
it("removes stale orphaned temp files", () => {
766+
withTempDir(async (dir) => {
767+
const storePath = path.join(dir, "sessions.json");
768+
const staleTemp = path.join(
769+
dir,
770+
"sessions.json.12345.0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b.tmp",
771+
);
772+
const freshTemp = path.join(
773+
dir,
774+
"sessions.json.12345.1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d.tmp",
775+
);
776+
777+
nodeFs.writeFileSync(staleTemp, "s".repeat(128), "utf-8");
778+
nodeFs.writeFileSync(freshTemp, "f".repeat(128), "utf-8");
779+
780+
// Make stale temp older than 5 minutes
781+
const old = new Date(Date.now() - 10 * 60 * 1000);
782+
nodeFs.utimesSync(staleTemp, old, old);
783+
784+
const result = sweepOrphanStoreTempsSync(dir, "sessions.json");
785+
786+
expect(nodeFs.existsSync(staleTemp)).toBe(false);
787+
expect(nodeFs.existsSync(freshTemp)).toBe(true);
788+
expect(result.removedFiles).toBe(1);
789+
expect(result.freedBytes).toBe(128);
790+
});
791+
});
792+
793+
it("returns zeros when no orphaned temps exist", () => {
794+
withTempDir(async (dir) => {
795+
const storePath = path.join(dir, "sessions.json");
796+
nodeFs.writeFileSync(storePath, JSON.stringify({}), "utf-8");
797+
798+
const result = sweepOrphanStoreTempsSync(dir, "sessions.json");
799+
800+
expect(result.removedFiles).toBe(0);
801+
expect(result.freedBytes).toBe(0);
802+
});
803+
});
804+
805+
it("skips non-matching temp files", () => {
806+
withTempDir(async (dir) => {
807+
const storePath = path.join(dir, "sessions.json");
808+
const otherTemp = path.join(dir, "other.json.12345.0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b.tmp");
809+
nodeFs.writeFileSync(storePath, JSON.stringify({}), "utf-8");
810+
nodeFs.writeFileSync(otherTemp, "x".repeat(64), "utf-8");
811+
812+
const old = new Date(Date.now() - 10 * 60 * 1000);
813+
nodeFs.utimesSync(otherTemp, old, old);
814+
815+
const result = sweepOrphanStoreTempsSync(dir, "sessions.json");
816+
817+
// other.json.*.tmp should NOT be removed when sweeping for sessions.json
818+
expect(nodeFs.existsSync(otherTemp)).toBe(true);
819+
expect(result.removedFiles).toBe(0);
820+
});
821+
});
822+
});

src/config/sessions/disk-budget.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,65 @@ function isUnreferencedSessionArtifactFile(
301301
// An orphaned `sessions.json.<pid>.<uuid>.tmp` older than this is never a live
302302
// atomic write (those rename within milliseconds), so it is safe to reclaim
303303
// regardless of the general unreferenced-artifact age threshold (#56827).
304-
const SESSION_STORE_TEMP_STALE_MS = 5 * 60 * 1000;
304+
export const SESSION_STORE_TEMP_STALE_MS = 5 * 60 * 1000;
305+
306+
/**
307+
* Reclaim orphaned atomic-write temp files in a sessions directory.
308+
*
309+
* `writeTextAtomic` stages into `<store>.<pid>.<uuid>.tmp` then renames.
310+
* If the process dies between write and rename, the temp is orphaned and
311+
* accumulates on disk (#56827). This sweep removes temps older than the
312+
* staleness window — safe because live atomic writes rename within ms.
313+
*
314+
* Call this once at gateway startup (or on first store load) to prevent
315+
* unbounded disk waste from repeated crash-orphan cycles.
316+
*/
317+
export function sweepOrphanStoreTempsSync(
318+
sessionsDir: string,
319+
storeBasename: string,
320+
options?: {
321+
staleMs?: number;
322+
log?: SessionDiskBudgetLogger;
323+
},
324+
): { removedFiles: number; freedBytes: number } {
325+
const staleMs = options?.staleMs ?? SESSION_STORE_TEMP_STALE_MS;
326+
const log = options?.log ?? NOOP_LOGGER;
327+
const cutoffMs = Date.now() - staleMs;
328+
let removedFiles = 0;
329+
let freedBytes = 0;
330+
331+
try {
332+
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
333+
for (const entry of entries) {
334+
if (!entry.isFile() || !isSessionStoreTempArtifactName(entry.name, storeBasename)) {
335+
continue;
336+
}
337+
const filePath = path.join(sessionsDir, entry.name);
338+
const stat = fs.statSync(filePath, { throwIfNoEntry: false });
339+
if (!stat?.isFile() || stat.mtimeMs > cutoffMs) {
340+
continue;
341+
}
342+
const size = stat.size;
343+
fs.rmSync(filePath, { force: true });
344+
removedFiles++;
345+
freedBytes += size;
346+
}
347+
} catch {
348+
// Directory may not exist yet (first run) — silently skip.
349+
return { removedFiles: 0, freedBytes: 0 };
350+
}
351+
352+
if (removedFiles > 0) {
353+
log.info("swept orphaned atomic-write temp files", {
354+
sessionsDir,
355+
storeBasename,
356+
removedFiles,
357+
freedBytes,
358+
});
359+
}
360+
361+
return { removedFiles, freedBytes };
362+
}
305363
// Prompt blobs are written or mtime-refreshed before sessions.json points at
306364
// them. Treat fresh unreferenced blobs as in-flight so cleanup cannot strand a
307365
// durable promptRef that is about to be committed by another writer.

src/gateway/server-startup-early.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,32 @@ export async function startGatewayEarlyRuntime(params: {
126126
taskRegistryMaintenance.startTaskRegistryMaintenance();
127127
getActiveTaskCount = () =>
128128
taskRegistryMaintenance.getInspectableActiveTaskRestartBlockers().length;
129+
130+
// Reclaim orphaned atomic-write temp files from previous crash cycles.
131+
// `writeTextAtomic` stages into `<store>.<pid>.<uuid>.tmp` then renames;
132+
// a crash between write and rename leaves the temp behind (#56827).
133+
void measureStartup(params.startupTrace, "runtime.early.orphan-tmp-sweep", async () => {
134+
const { sweepOrphanStoreTempsSync } = await import("../config/sessions/disk-budget.js");
135+
const { resolveStorePath } = await import("../config/sessions/paths.js");
136+
const { DEFAULT_AGENT_ID } = await import("../routing/session-key.js");
137+
const path = await import("node:path");
138+
const storePath = resolveStorePath(params.cfgAtStart.session?.store, {
139+
agentId: DEFAULT_AGENT_ID,
140+
});
141+
const sessionsDir = path.dirname(storePath);
142+
const storeBasename = path.basename(storePath);
143+
const result = sweepOrphanStoreTempsSync(sessionsDir, storeBasename, {
144+
log: {
145+
info: (msg: string, ctx?: Record<string, unknown>) => params.log.info(msg),
146+
warn: (msg: string, ctx?: Record<string, unknown>) => params.log.warn(msg),
147+
},
148+
});
149+
if (result.removedFiles > 0) {
150+
params.log.info(
151+
`swept ${result.removedFiles} orphaned .tmp files (${(result.freedBytes / 1024).toFixed(1)} KB freed)`,
152+
);
153+
}
154+
});
129155
}
130156

131157
const skillsChangeUnsub = params.minimalTestGateway

0 commit comments

Comments
 (0)