Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 37 additions & 27 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,33 +524,43 @@ export function recoverStaleResponseStateTemps(
let iterator: Iterator<string>;
try { iterator = names[Symbol.iterator](); } catch { return result; }
let scanned = 0;
for (;;) {
let next: IteratorResult<string>;
try { next = iterator.next(); } catch { return result; }
if (next.done) break;
const name = next.value;
scanned += 1;
if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
const match = RESPONSE_STATE_TEMP_NAME.exec(name);
if (!match) continue;
result.matched += 1;
const pid = Number(match[1]);
const sequence = Number(match[2]);
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(sequence) || sequence <= 0) continue;
const path = join(dir, name);
let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
try { file = io.inspect(path); } catch { continue; }
if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
if (pid === process.pid || io.isProcessAlive(pid)) continue;

try {
io.unlink(path);
result.removed += 1;
result.bytesRemoved += file.size;
} catch {
// Locked files remain for a later startup. Do not truncate by path: a same-user
// replacement could turn that fallback into an arbitrary symlink-target write.
result.failed += 1;
let exhausted = false;
try {
for (;;) {
let next: IteratorResult<string>;
try { next = iterator.next(); } catch { return result; }
if (next.done) {
exhausted = true;
break;
}
const name = next.value;
scanned += 1;
if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
const match = RESPONSE_STATE_TEMP_NAME.exec(name);
if (!match) continue;
result.matched += 1;
const pid = Number(match[1]);
const sequence = Number(match[2]);
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(sequence) || sequence <= 0) continue;
const path = join(dir, name);
let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
try { file = io.inspect(path); } catch { continue; }
if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
if (pid === process.pid || io.isProcessAlive(pid)) continue;

try {
io.unlink(path);
result.removed += 1;
result.bytesRemoved += file.size;
} catch {
// Locked files remain for a later startup. Do not truncate by path: a same-user
// replacement could turn that fallback into an arbitrary symlink-target write.
result.failed += 1;
}
}
} finally {
if (!exhausted) {
try { iterator.return?.(); } catch { /* Cleanup remains best-effort. */ }
}
}
return result;
Expand Down
16 changes: 16 additions & 0 deletions tests/responses-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,22 @@ describe("Responses previous_response_id state", () => {
expect(existsSync(join(home, second))).toBe(true);
});

test("stale temp recovery closes an enumeration iterator when it reaches a cap", () => {
let closed = false;
recoverStaleResponseStateTemps(home, {
list: function* () {
try {
yield "unrelated.txt";
} finally {
closed = true;
}
},
maxEntries: 0,
});

expect(closed).toBe(true);
});

test("stale temp recovery remains best-effort when enumeration fails", () => {
const result = recoverStaleResponseStateTemps(home, {
list: function* () {
Expand Down
Loading