Skip to content

Commit c68ca1c

Browse files
fix(sessions): sweep orphan store temp files
1 parent 4fa5092 commit c68ca1c

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Session store load tests cover startup sweep of orphaned atomic-write .tmp files.
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import { withTempDir } from "../../test-helpers/temp-dir.js";
6+
import { sweepOrphanSessionStoreTemps } from "./store-load.js";
7+
8+
describe("sweepOrphanSessionStoreTemps", () => {
9+
const uuid = "0f9c1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b";
10+
11+
it("deletes stale orphan temp files matching the store basename", async () => {
12+
await withTempDir({ prefix: "sweep-test" }, async (tmpDir) => {
13+
const storePath = path.join(tmpDir, "sessions.json");
14+
15+
// Create a stale orphan (older than 5 min)
16+
const staleOrphan = path.join(tmpDir, `sessions.json.12345.${uuid}.tmp`);
17+
fs.writeFileSync(staleOrphan, "stale", "utf-8");
18+
// Backdate mtime to 10 minutes ago
19+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
20+
fs.utimesSync(staleOrphan, tenMinAgo / 1000, tenMinAgo / 1000);
21+
22+
// Create a fresh orphan (younger than 5 min — should be preserved)
23+
const freshOrphan = path.join(tmpDir, `sessions.json.99999.${uuid}.tmp`);
24+
fs.writeFileSync(freshOrphan, "fresh", "utf-8");
25+
26+
// Create an unrelated temp file (should not be touched)
27+
const unrelated = path.join(tmpDir, "other.tmp");
28+
fs.writeFileSync(unrelated, "unrelated", "utf-8");
29+
fs.utimesSync(unrelated, tenMinAgo / 1000, tenMinAgo / 1000);
30+
31+
sweepOrphanSessionStoreTemps(storePath);
32+
33+
// Stale orphan matching the store basename should be deleted
34+
expect(fs.existsSync(staleOrphan)).toBe(false);
35+
// Fresh orphan should remain (not old enough)
36+
expect(fs.existsSync(freshOrphan)).toBe(true);
37+
// Unrelated temp files should not be touched
38+
expect(fs.existsSync(unrelated)).toBe(true);
39+
});
40+
});
41+
42+
it("does nothing when no orphan temp files exist", async () => {
43+
await withTempDir({ prefix: "sweep-empty" }, async (tmpDir) => {
44+
const storePath = path.join(tmpDir, "sessions.json");
45+
fs.writeFileSync(storePath, "{}", "utf-8");
46+
47+
expect(() => sweepOrphanSessionStoreTemps(storePath)).not.toThrow();
48+
});
49+
});
50+
51+
it("handles non-existent store directory gracefully", () => {
52+
const storePath = "/tmp/nonexistent-dir-89520/sessions.json";
53+
expect(() => sweepOrphanSessionStoreTemps(storePath)).not.toThrow();
54+
});
55+
56+
it("only deletes temp files for the matching store basename", async () => {
57+
await withTempDir({ prefix: "sweep-multi" }, async (tmpDir) => {
58+
const storePath = path.join(tmpDir, "my-sessions.json");
59+
const tenMinAgo = Date.now() - 10 * 60 * 1000;
60+
61+
const matchingOrphan = path.join(tmpDir, `my-sessions.json.12345.${uuid}.tmp`);
62+
fs.writeFileSync(matchingOrphan, "match", "utf-8");
63+
fs.utimesSync(matchingOrphan, tenMinAgo / 1000, tenMinAgo / 1000);
64+
65+
const otherStoreOrphan = path.join(tmpDir, `other-sessions.json.12345.${uuid}.tmp`);
66+
fs.writeFileSync(otherStoreOrphan, "other", "utf-8");
67+
fs.utimesSync(otherStoreOrphan, tenMinAgo / 1000, tenMinAgo / 1000);
68+
69+
sweepOrphanSessionStoreTemps(storePath);
70+
71+
expect(fs.existsSync(matchingOrphan)).toBe(false);
72+
expect(fs.existsSync(otherStoreOrphan)).toBe(true);
73+
});
74+
});
75+
});

src/config/sessions/store-load.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Session store loading normalizes persisted records, migrations, maintenance, and caches.
22
import fs from "node:fs";
3+
import path from "node:path";
34
import { isRecord } from "@openclaw/normalization-core/record-coerce";
45
import { createSubsystemLogger } from "../../logging/subsystem.js";
56
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
@@ -11,6 +12,7 @@ import {
1112
normalizeSessionDeliveryFields,
1213
} from "../../utils/delivery-context.shared.js";
1314
import { getFileStatSnapshot } from "../cache-utils.js";
15+
import { isSessionStoreTempArtifactName } from "./artifacts.js";
1416
import { hydrateSessionStoreSkillPromptRefs } from "./skill-prompt-blobs.js";
1517
import {
1618
cloneSessionStoreRecord,
@@ -373,6 +375,54 @@ export function normalizeSessionStore(store: Record<string, SessionEntry>): bool
373375
return changed;
374376
}
375377

378+
/**
379+
* Delete orphaned `<store>.<pid>.<uuid>.tmp` temp files left behind when
380+
* `replaceFileAtomic` crashes between write and rename (#89520).
381+
* Only files older than 5 minutes are removed to avoid racing live writers.
382+
*/
383+
export function sweepOrphanSessionStoreTemps(storePath: string): void {
384+
const storeDir = path.dirname(storePath);
385+
const storeBasename = path.basename(storePath);
386+
let dir: fs.Dir | undefined;
387+
try {
388+
dir = fs.opendirSync(storeDir);
389+
} catch {
390+
// Store directory may not exist yet (first launch) — nothing to sweep.
391+
return;
392+
}
393+
try {
394+
const cutoffMs = Date.now() - 5 * 60 * 1000;
395+
let deleted = 0;
396+
for (let entry = dir.readSync(); entry; entry = dir.readSync()) {
397+
if (!entry.isFile()) {
398+
continue;
399+
}
400+
if (!isSessionStoreTempArtifactName(entry.name, storeBasename)) {
401+
continue;
402+
}
403+
const fullPath = path.join(storeDir, entry.name);
404+
try {
405+
const stat = fs.statSync(fullPath);
406+
if (stat.mtimeMs > cutoffMs) {
407+
continue;
408+
}
409+
fs.unlinkSync(fullPath);
410+
deleted += 1;
411+
} catch {
412+
// racing writer or concurrent sweep — skip
413+
}
414+
}
415+
if (deleted > 0) {
416+
log.info("deleted orphaned session store temp files", {
417+
storePath,
418+
count: deleted,
419+
});
420+
}
421+
} finally {
422+
dir?.closeSync();
423+
}
424+
}
425+
376426
export function loadSessionStore(
377427
storePath: string,
378428
opts: LoadSessionStoreOptions = {},
@@ -392,6 +442,9 @@ export function loadSessionStore(
392442
}
393443
}
394444

445+
// Sweep orphaned .tmp files left by crashed atomic writes before touching the store.
446+
sweepOrphanSessionStoreTemps(storePath);
447+
395448
// Retry a few times on Windows because readers can briefly observe empty or
396449
// transiently invalid content while another process is swapping the file.
397450
let store: Record<string, SessionEntry> = {};

0 commit comments

Comments
 (0)