Skip to content

Commit 141736d

Browse files
lifraryclaude
andcommitted
fix(responses): bound the durable spill directory with an aggregate byte cap
The response store has an unconditional RAM ceiling (MAX_STORED_RESPONSE_BYTES, 64 MiB) and demotes the oldest resident entry to a durable spill once it is crossed. Nothing bounded where those bytes landed: the spilled set was capped only per file (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per entry (MAX_STORED_RESPONSES, 1000). Their product is 250 GiB, larger than the disk of any host this runs on, so the only effective bound was RESPONSE_TTL_MS and disk use became a function of client request rate rather than of anything this process controls. Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at ~1.4/min held 6.8 GB of ~/.opencodex/responses-state-spill after 44 minutes and was still climbing toward the ~12 GB an hour-long window implies. It filled the volume, at which point unrelated processes began failing with ENOSPC. Retention itself was correct throughout - the TTL evicted that whole cohort an hour later - so this is a missing budget, not a leak. Add MAX_SPILLED_RESPONSE_BYTES (1 GiB) and evict past it in pruneResponses, immediately after the RAM demotion loop that creates the pressure. deleteEntry already routes through deleteOwnedSpills, so an evicted entry unlinks its file. Eviction is ordered by createdAt, not by map order. `states` is not an age index: demotion and spill replacement delete and reinsert entries, and writeBoundedSnapshot serializes the map reversed, so map order can put a newer continuation first and evicting that one spends a resume the older entry would not have cost. createdAt is millisecond-resolution and ties are ordinary under load, where a stable sort would fall back to insertion order, so ties break on the response id - by direct comparison rather than localeCompare, since the order must not depend on the host locale. The spilled total is recomputed per prune rather than carried as a running counter: spilled entries reach `states` through several insertion paths (demotion swap, direct oversized admission, snapshot reload), and one missed increment there would silently disable the cap, where a walk over at most MAX_STORED_RESPONSES entries cannot drift. 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly bimodal: median 1.1 MiB against a p90 of 198.7 MiB. At that median the count cap and this ceiling bind within 8% of each other (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not already have seen and only the large tail is cut. The value is the one knob here a maintainer may reasonably want to change. The spill directory was documented nowhere, while the state-root inventory describes its group as bounded caches, so structure/00 and structure/02 now name it and its aggregate bound. Each regression test was confirmed to fail without the code it covers: the budget test retains 48564 bytes against a 20000-byte cap without the eviction loop, the ordering test keeps the older spill without the createdAt sort, and the tie test does the same without the id tie-breaker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 870a2ad commit 141736d

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

src/responses/state.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
2828
* continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
2929
* so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
3030
export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
31+
/**
32+
* Aggregate ceiling for the durable spill directory: the disk-side counterpart to
33+
* the RAM ceiling above. Without it the spilled set is bounded only per-file
34+
* (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per-entry (MAX_STORED_RESPONSES,
35+
* 1000), whose product is 250 GiB — larger than the disk of any host this runs on.
36+
* The only effective bound was therefore RESPONSE_TTL_MS, which makes disk use a
37+
* function of client request rate rather than of anything this process controls.
38+
*
39+
* Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at
40+
* ~1.4/min held 6.8 GB after 44 minutes, still climbing toward the ~12 GB an
41+
* hour-long window implies, and filled the volume. Retention itself was correct
42+
* throughout — the TTL evicted that whole cohort an hour later — so what was
43+
* missing is a budget, not a sweep.
44+
*
45+
* 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly
46+
* bimodal: median 1.1 MiB against a p90 of 198.7 MiB, near the per-file ceiling.
47+
* At that median the count cap and this ceiling bind within 8% of each other
48+
* (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not
49+
* already have seen and only the large tail is cut. Erring small is the safe
50+
* direction: too low costs a replay miss, an already-handled path surfaced as
51+
* previous_response_not_found, while too high costs the host's disk and every
52+
* unrelated process on it.
53+
*/
54+
export const MAX_SPILLED_RESPONSE_BYTES = 1024 * 1024 * 1024;
3155
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
3256
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
3357
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
@@ -174,6 +198,37 @@ export function getStoredResponseBytesForTests(): number {
174198
return storedResponseBytes;
175199
}
176200

201+
let spillByteCapOverride: number | null = null;
202+
203+
function spillByteCap(): number {
204+
return spillByteCapOverride ?? MAX_SPILLED_RESPONSE_BYTES;
205+
}
206+
207+
/**
208+
* Live total of durable spill payloads. Recomputed per call rather than carried as
209+
* a running counter: spilled entries reach `states` through several insertion paths
210+
* (demotion swap, direct oversized admission, snapshot reload), and one missed
211+
* increment there would silently disable the cap, where an O(MAX_STORED_RESPONSES)
212+
* walk cannot drift.
213+
*/
214+
function spilledResponseBytes(): number {
215+
let total = 0;
216+
for (const entry of states.values()) {
217+
if (entry.kind === "spill") total += entry.spill.payloadBytes;
218+
}
219+
return total;
220+
}
221+
222+
/** Test-only: lower/restore the durable spill cap (null restores the default). */
223+
export function setSpilledResponseByteCapForTests(bytes: number | null): void {
224+
spillByteCapOverride = bytes;
225+
}
226+
227+
/** Test-only: current durable spill accounting (proves evictions unlink their files). */
228+
export function getSpilledResponseBytesForTests(): number {
229+
return spilledResponseBytes();
230+
}
231+
177232
function serializedBytes(value: unknown): number | null {
178233
try {
179234
const serialized = JSON.stringify(value);
@@ -1070,6 +1125,31 @@ function pruneResponses(at = now()): void {
10701125
replaceWithSpillFailure(oldestId, entry);
10711126
}
10721127
}
1128+
// Disk counterpart to the RAM cap above. Demotion moves bytes out of memory but
1129+
// nothing bounded where they land, so evict oldest-first until the durable set
1130+
// fits. deleteEntry routes through deleteOwnedSpills, which unlinks the file.
1131+
let spilledBytes = spilledResponseBytes();
1132+
if (spilledBytes > spillByteCap()) {
1133+
// Ordered by createdAt, not by map order. `states` is not an age index:
1134+
// demotion and spill replacement delete and reinsert entries, and
1135+
// writeBoundedSnapshot serializes the map reversed, so map order can put a
1136+
// newer continuation first — and evicting that one spends a resume the older
1137+
// entry would not have cost. Sorting is O(k log k) over the spilled subset
1138+
// and runs only on a tick already over budget.
1139+
const spilled = [...states]
1140+
.filter((pair): pair is [string, SpilledResponseState] => pair[1].kind === "spill")
1141+
// createdAt is millisecond-resolution, so ties are ordinary under load. A
1142+
// stable sort would then fall back to insertion order — the very order this
1143+
// is avoiding — so break ties on the response id. Not localeCompare: the
1144+
// order must not depend on the host locale.
1145+
.sort((a, b) => a[1].createdAt - b[1].createdAt
1146+
|| (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
1147+
for (const [id, entry] of spilled) {
1148+
if (spilledBytes <= spillByteCap()) break;
1149+
spilledBytes -= entry.spill.payloadBytes;
1150+
deleteEntry(id);
1151+
}
1152+
}
10731153
}
10741154

10751155
/** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */

structure/00_overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th
8484
| `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. `runtime-port.json` also carries the protected per-process listener-attestation key used before CLI diagnostics attach a management bearer. |
8585
| `~/.opencodex/codex-runtime.json`, `codex-runtime-clamp.json` | opencodex Codex runtime | Selected Codex executable/version state and effort-clamp diagnostics. Not process identity: these persist a resolved choice and a diagnostic, so losing them changes behavior until re-resolved. |
8686
| `~/.opencodex/service-state.json`, `service.log`, `service-api-token`, `opencodex-service-launcher.vbs`, `opencodex-service-task.xml`, `opencodex-service.cmd`, `winsw`, `tray-state.json`, `tray-heartbeat.json`, `opencodex-tray.ps1`, `opencodex-tray-*.ico`, `update-job.json` | opencodex operators | Installed-service, Windows tray, and self-update artifacts and bookkeeping. The update record carries its worker PID so a dead worker recovers instead of blocking later runs. |
87-
| `~/.opencodex/responses-state.json`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. |
87+
| `~/.opencodex/responses-state.json`, `responses-state-spill/`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. The spill directory holds continuation state demoted out of the in-memory cap and is bounded in aggregate, not only per file. |
8888
| `~/.opencodex/codex-shim.json`, `*.lock`, `kimi-device-id`, `mimo-client-id`, `.star-prompted` | opencodex bookkeeping | Shim restore obligations, cross-process locks, per-install client identifiers, one-shot UI flags. |
8989
| `~/.opencodex/.opencodex-owner.json`, `.opencodex-uninstall.json` | opencodex | Ownership marker and the manifest that bounds what uninstall may remove. Both live in the OpenCodex state root, not in `$CODEX_HOME`. |
9090
| `$CODEX_HOME/config.toml` | Codex, edited by opencodex | Active provider and provider table. |

structure/02_config-and-codex-home.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ Hidden` inside an already-running PowerShell script, nor to .NET/VBS process-win
157157
- 다른 대안 대신 이 방식을 선택한 이유: Names and environment paths are caller-controlled, required secret writes must not silently skip ACLs, and elevation has a larger authority boundary that should remain FFI-only.
158158
- 장점, 단점 및 영향: Default Windows ARM64 installations can start and harden secrets; non-default Windows roots continue to fail closed until Bun exposes a trustworthy native system-directory API without FFI.
159159

160+
The durable response-spill directory `~/.opencodex/responses-state-spill/` is bounded in
161+
aggregate, not only per file. Continuation state demoted out of the in-memory cap
162+
(`MAX_STORED_RESPONSE_BYTES`) is written there, and eviction past
163+
`MAX_SPILLED_RESPONSE_BYTES` removes oldest-first through the same deletion point that serves
164+
TTL and count eviction, so an evicted entry unlinks its file. Without that aggregate bound the
165+
directory was limited only per file (256 MiB) and per entry (1000) — a 250 GiB product — which
166+
left `RESPONSE_TTL_MS` as the only effective limit and made disk use a function of client
167+
request rate rather than of anything the process controls.
168+
169+
[Decision Log]
170+
- 목적과 의도: Bound the durable spill directory in aggregate so demoted continuation state cannot consume the host disk.
171+
- 기존 구현 및 제약 조건: The resident map has an unconditional byte cap and demotes past it, but the disk it demotes onto had only a per-file ceiling and the shared 1000-entry count cap. Retention itself worked — the hour-long TTL did evict — so the gap was a missing budget, not a leak.
172+
- 검토한 주요 대안: Lower the per-file ceiling; shorten the TTL; sweep the directory on a timer; add a configurable budget key; carry a running byte counter.
173+
- 선택한 방식: A constant aggregate ceiling checked at the end of the existing prune, evicting oldest-first, with the total recomputed per prune rather than carried as a counter.
174+
- 다른 대안 대신 이 방식을 선택한 이유: Per-file or TTL changes alter retention semantics other bounds depend on; a timer adds a second owner for eviction; a config key would surface a knob the sibling bounds (count, TTL, per-file) do not have; and a running counter could silently disable the cap if any of the several insertion paths missed an increment, where a walk over at most 1000 entries cannot drift.
175+
- 장점, 단점 및 영향: Disk use stops tracking client request rate. Ordinary traffic is unaffected because the count cap binds at a comparable point for median-sized payloads; a workload of unusually large continuations loses its oldest spills earlier than the TTL would, surfacing as the existing `previous_response_not_found` continuation miss.
176+
160177
Response-state loading performs a bounded recovery pass for interrupted snapshot writes. It only
161178
matches regular files named `responses-state.json.ocx.<pid>.<sequence>.tmp`, waits at least 15
162179
minutes, and skips the current or any live PID. Eligible files are truncated before unlinking so a

tests/responses-state.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
responseContinuationRetainedStoreSnapshot,
4545
runPendingResponseStatePersistForTests,
4646
setResponseStateByteCapForTests,
47+
setSpilledResponseByteCapForTests,
48+
getSpilledResponseBytesForTests,
4749
setResponseStatePersistAttemptHookForTests,
4850
getStoredResponseBytesForTests,
4951
} from "../src/responses/state";
@@ -172,6 +174,7 @@ describe("Responses previous_response_id state", () => {
172174
setPlatformForTests(null);
173175
resetHardenedStateForTests();
174176
setResponseStateByteCapForTests(null);
177+
setSpilledResponseByteCapForTests(null);
175178
clearResponseStateForTests();
176179
rmSync(home, { recursive: true, force: true });
177180
if (priorHome === undefined) delete process.env["OPENCODEX_HOME"];
@@ -702,6 +705,21 @@ describe("Responses previous_response_id state", () => {
702705
}) as { input: unknown[] }).input).toHaveLength(3);
703706
});
704707

708+
test("evicts oldest spills once the durable set exceeds the disk cap", () => {
709+
setResponseStateByteCapForTests(1_024);
710+
setSpilledResponseByteCapForTests(20_000);
711+
for (let i = 0; i < 6; i += 1) rememberLarge(`resp_spill_budget_${i}`, "x".repeat(8_000));
712+
// Without a disk cap all six stay on disk: the RAM cap only moves bytes out of
713+
// memory, it never bounds where they land.
714+
expect(getSpilledResponseBytesForTests()).toBeLessThanOrEqual(20_000);
715+
expect(spillFileNames(home).length).toBeLessThan(6);
716+
// Eviction is oldest-first and must not clear the set it was asked to bound.
717+
expect(spillFileNames(home).length).toBeGreaterThanOrEqual(1);
718+
expect((expandPreviousResponseInput({
719+
previous_response_id: "resp_spill_budget_5", input: "next",
720+
}) as { input: unknown[] }).input).toHaveLength(3);
721+
});
722+
705723
test("does not swap a resident row to a stub before fsync and no-replace publication succeed", () => {
706724
const events: string[] = [];
707725
setSpillIoForTest({ record: event => events.push(event) });
@@ -940,6 +958,63 @@ describe("Responses previous_response_id state", () => {
940958
expect(spillFileNames(home)).toHaveLength(1);
941959
});
942960

961+
test("evicts by createdAt, not by insertion order", () => {
962+
const realNow = Date.now;
963+
try {
964+
setResponseStateByteCapForTests(1_024);
965+
// Insert the NEWER entry first so insertion order and createdAt order
966+
// disagree. Map order alone would evict the newer one; `states` is not a
967+
// reliable age index — writeBoundedSnapshot even serializes it reversed.
968+
const base = realNow();
969+
Date.now = () => base;
970+
const beforeNewer = new Set(spillFileNames(home));
971+
rememberLarge("resp_order_newer", "x".repeat(8_000));
972+
const newerFile = spillFileNames(home).find(name => !beforeNewer.has(name))!;
973+
974+
Date.now = () => base - 5 * 60_000;
975+
const beforeOlder = new Set(spillFileNames(home));
976+
rememberLarge("resp_order_older", "x".repeat(8_000));
977+
const olderFile = spillFileNames(home).find(name => !beforeOlder.has(name))!;
978+
Date.now = () => base;
979+
980+
// Each spill payload is ~16.2 KB, so 20_000 leaves room for exactly one.
981+
setSpilledResponseByteCapForTests(20_000);
982+
rememberLarge("resp_order_trigger", "y");
983+
984+
const dir = responseSpillDirectory(home);
985+
expect(existsSync(join(dir, olderFile))).toBe(false);
986+
expect(existsSync(join(dir, newerFile))).toBe(true);
987+
} finally {
988+
Date.now = realNow;
989+
}
990+
});
991+
992+
test("breaks createdAt ties on the response id, not on insertion order", () => {
993+
const realNow = Date.now;
994+
try {
995+
setResponseStateByteCapForTests(1_024);
996+
const base = realNow();
997+
Date.now = () => base;
998+
// Same createdAt, inserted in reverse id order: a stable sort alone would
999+
// keep insertion order and evict "_b" first.
1000+
const before = new Set(spillFileNames(home));
1001+
rememberLarge("resp_tie_b", "x".repeat(8_000));
1002+
const bFile = spillFileNames(home).find(name => !before.has(name))!;
1003+
const beforeA = new Set(spillFileNames(home));
1004+
rememberLarge("resp_tie_a", "x".repeat(8_000));
1005+
const aFile = spillFileNames(home).find(name => !beforeA.has(name))!;
1006+
1007+
setSpilledResponseByteCapForTests(20_000);
1008+
rememberLarge("resp_tie_trigger", "y");
1009+
1010+
const dir = responseSpillDirectory(home);
1011+
expect(existsSync(join(dir, aFile))).toBe(false);
1012+
expect(existsSync(join(dir, bFile))).toBe(true);
1013+
} finally {
1014+
Date.now = realNow;
1015+
}
1016+
});
1017+
9431018
test("TTL and count eviction delete dedicated spill files and release stub bytes", () => {
9441019
const realNow = Date.now;
9451020
setResponseStateByteCapForTests(1_024);

0 commit comments

Comments
 (0)