diff --git a/src/responses/state.ts b/src/responses/state.ts index 2e7c2abb6..aab4c546d 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -524,33 +524,43 @@ export function recoverStaleResponseStateTemps( let iterator: Iterator; try { iterator = names[Symbol.iterator](); } catch { return result; } let scanned = 0; - for (;;) { - let next: IteratorResult; - 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; - 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; + 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; + 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; diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 2eff46e7b..83d20b87a 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -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* () {