You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: structure/00_overview.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -84,7 +84,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th
84
84
|`~/.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. |
85
85
|`~/.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. |
86
86
|`~/.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. |
|`~/.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`. |
90
90
|`$CODEX_HOME/config.toml`| Codex, edited by opencodex | Active provider and provider table. |
Copy file name to clipboardExpand all lines: structure/02_config-and-codex-home.md
+17Lines changed: 17 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -157,6 +157,23 @@ Hidden` inside an already-running PowerShell script, nor to .NET/VBS process-win
157
157
- 다른 대안 대신 이 방식을 선택한 이유: 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.
158
158
- 장점, 단점 및 영향: 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.
159
159
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
+
160
177
Response-state loading performs a bounded recovery pass for interrupted snapshot writes. It only
161
178
matches regular files named `responses-state.json.ocx.<pid>.<sequence>.tmp`, waits at least 15
162
179
minutes, and skips the current or any live PID. Eligible files are truncated before unlinking so a
0 commit comments