Plugin catalog cache is rewritten in full after every `plugin/list` — no TTL, no content check, ~65 GB/day of disk writes
Summary
~/.codex/cache/remote_plugin_catalog/<hash>.json is rewritten in its entirety after everyplugin/list request. There is no TTL and no comparison against the content already on disk, so
an unchanged 7.7 MB JSON file is rewritten every few seconds for as long as a TUI session is open.
Measured on an idle machine: 302 MB written in 421 seconds (~65 GB/day) from this one file, and
64% of those writes were byte-identical to the content they replaced.
This is a different mechanism from the logs_2.sqlite write volume already tracked in #17320,
#28224, #30236, #31132 and #34291. Those are SQLite writes to a diagnostic log. These are plain
whole-file rewrites of a cache — a file whose purpose is to avoid work.
On this machine the cache files now write more to the internal SSD than the diagnostic log does,
because the log was moved to a RAM disk while the cache was not.
Environment
- Codex CLI 0.145.0 (Homebrew), plus a resident 0.144.5
app-serversharing the sameCODEX_HOME - macOS (Darwin 25.3.0), Apple Silicon, internal
APPLE SSD AP0512Z - Remote plugin catalog contains roughly 40 plugins; the cached JSON is 7.7 MB
Measurement
Method: stat the file every 0.3 s; on every mtime change, read it and take a SHA-256 of the
contents. No writes to ~/.codex were made by the measurement.
Window: 421 seconds, one interactive session active.
| file | size | rewrites | interval | bytes written | rate |
| --- | ---: | ---: | ---: | ---: | ---: |
| cache/remote_plugin_catalog/<hash>.json | 7.7 MB | 55 | 8.6 s | 302 MB | 65.1 GB/day |
| models_cache.json | 0.29 MB | 63 | 6.7 s | 16.6 MB | 3.6 GB/day |
| cache/codex_apps_tools/<hash>.json | 1.3 MB | 7 | 60 s | 9.2 MB | 2.0 GB/day |
| total | | | | | 70.7 GB/day |
For the plugin catalog:
- 35 of the 55 rewrites (64%) were byte-identical to the content they replaced.
- Only two distinct contents were ever observed across all 55 rewrites (7.747 MB and 7.585 MB).
- So 302 MB of writes carried at most 15.3 MB of distinct information.
In a separate window with no session activity, the catalog was not rewritten at all, so the
rewrites track plugin/list traffic rather than a background timer.
Device-level confirmation used the IOBlockStorageDriver Bytes (Write) counter in ioreg,
the same counter used in #29876.
Root cause
Read from tag rust-v0.145.0 (commit 25af12f7e61572b0bc18ddb1008be543b91519b0), codex-rs/:
The catalog is written whole, unconditionally
core-plugins/src/remote/catalog_cache.rs:77-98—RemotePluginCatalogDiskCache::writeserializes
{schema_version, plugins} and calls std::fs::write on the whole file. There is no partial update.
- There is no comparison against the existing file before writing.
A refresh is scheduled after every plugin/list, even when the cache was just used
app-server/src/request_processors/plugins.rs:736-749— after answeringplugin/listfrom the
existing cache, a background refresh is queued.
core-plugins/src/manager.rs:1040-1076,:2465-2500— the background worker fetches and calls
the writer again.
core-plugins/src/remote.rs:679-746,:842-851— fetch path into the same writer.
There is no TTL
core-plugins/src/remote/catalog_cache.rs:38-74— the cache is only rejected when the file is
missing, unreadable, malformed, or the schema version differs. No age check.
The TUI polls plugin/list on a short interval (measured here at 8.6–13.9 s depending on how many
sessions are open), so the effective behaviour is "rewrite 7.7 MB every polling interval, forever."
Amplification when two Codex versions share one CODEX_HOME
Not the primary cause, but it adds writes and is easy to hit (Homebrew, ~/.local/bin, and the
ChatGPT desktop app each install their own binary).
core-plugins/src/remote/catalog_cache.rs:13-29— the cache filename hash is derived from
chatgpt_base_url, account_id, chatgpt_user_id and is_workspace_account.
client_version is not part of the key, so different versions share one file.
models-manager/src/cache.rs:30-73—models_cache.jsondoes recordclient_version, and a
mismatch invalidates the cache. Two versions therefore invalidate each other's entry
indefinitely; each one re-fetches /models and rewrites the whole file.
Observed directly: version 0.144.5 wrote models_cache.json, and 0.145.0 overwrote it
1.2 seconds later, with a different etag — i.e. both had made a network request.
This also explains the two distinct catalog contents (7.747 MB / 7.585 MB) alternating in the
measurement above.
Impact
- Continuous SSD writes proportional to the number of open sessions, on hardware with
non-replaceable storage.
- Every rewrite is preceded by a network fetch, so this is also redundant request volume.
models_cache.jsonis re-fetched and rewritten even when the returned etag is unchanged, purely
to update fetched_at (models-manager/src/cache.rs:94-102).
Expected behaviour
- Skip the write when the serialized content is unchanged. This alone removes 64% of the
observed writes and is a small, local change at
core-plugins/src/remote/catalog_cache.rs:77-98.
- Add a TTL to the plugin catalog cache, as
models_cache.jsonalready has (300 s,
models-manager/src/manager.rs:26-27).
- Do not schedule a background refresh after every
plugin/listwhen the cache was just
served and is within its TTL.
- **Either include
client_versionin the cache key, or stop treating a version mismatch as
invalidation**, so that multiple installed versions do not fight over one file.
Related issues
- #25758, #28277 — the same plugin cache files being rewritten, reported as a functional problem
(plugins disappearing) rather than as write volume.
- #29876 — macOS SSD wear measured with the same
ioregcounter (1.05 GB / 2 min under active use).
Closed with no maintainer response; the sources identified there were code_sign_clone and
logs_2.sqlite, not the caches.
- #28093 — Windows: a bundled runtime redeployed on every start, the same
"rewrite the same bytes repeatedly" shape.
- #17320, #28224, #31132, #34291 —
logs_2.sqlitewrite volume. Separate mechanism.
Reproduction
With an interactive codex TUI session open and remote plugins enabled:
F=~/.codex/cache/remote_plugin_catalog/*.json
prev=""
for i in $(seq 1 200); do
h=$(shasum -a 256 $F | cut -d' ' -f1)
[ "$h" != "$prev" ] && echo "$(date +%T) $(stat -f%z $F) $h"
prev=$h
sleep 1
done
Repeated lines with the same hash are byte-identical rewrites.
3 Comments
Independent reproduction on macOS (Codex CLI 0.145.0 + Desktop 26.721.41059)
Summary
I independently reproduced the core behavior described in this issue on a second macOS machine:
models_cache.jsonwas also rewritten frequently and independently of the catalog;This confirms the unnecessary whole-file rewrite mechanism, but does not independently confirm that the ~65 GB/day catalog rate is sustained indefinitely on every setup.
Environment
Mac16,8, Apple M4 Pro, 48 GB RAM, arm640.145.0at~/.local/bin/codex26.721.41059(bundle build5848)0.146.0-alpha.3.1CODEX_HOME:~/.codex0.146.0-alpha.3.1 app-serverprocesses were already runningThe standalone TUI was launched with:
The automated PTY reported
TERM=dumb, so its long-running UI polling behavior may differ from a normal terminal. Remote plugins and the configured MCP servers were enabled.Measurement
I checked each file every 0.25 seconds. On every mtime change, I recorded:
client_versionandfetched_at.The observer only read files and did not write to
~/.codex.Two windows were measured:
Results
| file / window | size | rewrites | bytes rewritten | notes |
| --- | ---: | ---: | ---: | --- |
|
remote_plugin_catalog/<hash>.json, desktop-only 90 s | 8.12 MB | 0 | 0 | no catalog churn in this baseline ||
remote_plugin_catalog/<hash>.json, TUI 80 s | 8,123,134 B | 3 | 24,369,402 B | 2 of 3 rewrites were byte-identical ||
models_cache.json, combined 170 s | 304,142 B | 29 | 8,820,118 B | 12 writes in the baseline window, 17 with the TUI open ||
codex_apps_tools/<hash>.json, initial 90 s | 1,652,129 B | 0 | 0 | no changes observed |If the observed
models_cache.jsonrate were sustained, it would be approximately 4.48 GB/day (decimal). This is close to the separate model-cache rate reported in the issue.Plugin catalog observations
Initial state before starting the TUI:
c85219a3401d7689e4e2b92986044f3c1cdc811d527a51cfa078c345221877dcfetched_at: absent /nullAfter starting the TUI, the catalog changed at:
| local time (JST) | size | SHA-256 |
| --- | ---: | --- |
| 11:54:38 | 8,123,134 B |
9c29d7adcc210966329fc8e964801791a3118e84b411d8609aa4a7cc1e968221|| 11:54:45 | 8,123,134 B |
9c29d7adcc210966329fc8e964801791a3118e84b411d8609aa4a7cc1e968221|| 11:54:51 | 8,123,134 B |
9c29d7adcc210966329fc8e964801791a3118e84b411d8609aa4a7cc1e968221|The latter two writes replaced the file with exactly the same bytes at intervals of 7 and 6 seconds.
No additional catalog rewrite was observed between 11:54:51 and the end of the 80-second window at 11:55:47, even though the TUI remained open. The test TUI was then closed.
So, on this machine I reproduced the whole-file, byte-identical rewrite behavior, but only as a startup burst. I am not extrapolating this short burst into a daily catalog-write figure.
models_cache.jsonobservationsThe model cache continued to change before and during the standalone TUI test:
client_versionwas0.146.0;fetched_atchanged on each captured rewrite.This churn was already present during the desktop-only baseline, so it was not solely caused by launching CLI 0.145.0. Multiple resident
app-serverprocesses sharing oneCODEX_HOMEmay be relevant, but this measurement does not establish causality.The source path appears consistent with
ModelsCacheManager::renew_cache_ttlupdatingfetched_atandsave_internalrewriting the full JSON file.Source/build note
I also confirmed that current
maincontains commit83ff1c2, which adds a three-hour remote catalog TTL and stale-only refresh behavior.The cache written by the installed builds during this test still had
fetched_at = null, consistent with the pre-TTL format. The installed standalone CLI 0.145.0 therefore remains affected by the code path described in the issue.The main-branch TTL should substantially reduce repeated refreshes once it reaches released builds, although the writer still does not appear to skip a write based on serialized-content equality.
Impact / request
This provides an independent confirmation that:
plugin/list-related activity can cause repeated full rewrites of an 8+ MB remote catalog;models_cache.jsonhas a separate frequent-rewrite problem worth tracking;CODEX_HOMEdeserve explicit coverage in regression tests.It would be helpful to confirm:
app-serverprocesses sharing oneCODEX_HOMEare expected and tested.Thank you for the independent reproduction — this is exactly the kind of second data point the report needed, and your caveat is fair. Let me accept it explicitly and then answer what I could verify.
Accepting the rate caveat
You are right that ~65 GB/day should not be read as a universal sustained rate. My measurement came from an environment with six resident
app-serverprocesses plus an interactive TUI, and the rewrites in my window tracked the TUI's ~16-secondplugin/listpolling. Your run used a PTY that reportedTERM=dumb, and you noted yourself that its polling behavior may differ.That difference is a sufficient explanation for burst-vs-sustained: if the rewrite is driven by
plugin/list, then the rate is a function of how often something calls it, not a property of the catalog. I am restating my figure as "~65 GB/day under continuousplugin/listpolling from resident sessions" rather than as a general rate. The mechanism — full rewrite with no content check — is what I would like to see treated as the defect.Which release contains the three-hour TTL
You asked which stable release includes
83ff1c2. I checked the tags:| tag | contains
83ff1c2|| --- | --- |
|
rust-v0.145.0(latest stable) | no ||
rust-v0.146.0-alpha.3/alpha.3.1| no ||
rust-v0.146.0-alpha.4| no ||
rust-v0.146.0-alpha.5and later | yes |So the TTL first shipped in
0.146.0-alpha.5(2026-07-23 20:02 UTC), about 20 hours after the commit landed. Your bundled0.146.0-alpha.3.1was tagged later in wall-clock time (07-23 23:26 UTC) but branches offalpha.3, which is why it still wrotefetched_at: null— that observation is consistent, not anomalous.No stable release contains it as of this writing.
Content equality — confirmed absent, and harder to add than it looks
Your reading is correct.
write_cached_directory_pluginsserializes and writes unconditionally:Worth flagging for whoever picks this up: a naive equality check on the serialized bytes would never fire, because
fetched_at: Some(Utc::now())is embedded in the same document and changes on every call. The comparison has to be against thepluginspayload specifically, withfetched_ateither excluded or refreshed in place.A case where the TTL does not help: mixed versions sharing one
CODEX_HOMEThis follows from
is_fresh:A pre-TTL build (0.145.0 and earlier) writes an entry with no
fetched_at. A post-TTL build reading that entry getsStaleevery time, so it refetches and rewrites on every list — and the pre-TTL build then overwrites it right back without a timestamp. The two keep each other permanently stale, and the three-hour TTL never takes effect.This matters because mixed installations are common rather than exotic. On my machine there are three independent install paths sharing
~/.codex:/opt/homebrew/bin/codex)~/.local/bin/codexUpdating one does not update the others, and
app-serverruns with--listenand does not exit on its own, so a stale resident process can keep a pre-TTL writer alive for days. I had one running for 4 days 19 hours on an older binary.This seems worth a regression test, and it lines up with your fourth request about multiple processes sharing one
CODEX_HOME.Summary of what I think remains after the TTL lands
forceRefetchbypasses the TTL entirely).fetched_at.CODEX_HOMEdefeat the TTL completely.models_cache.jsonis a separate path — your 4.48 GB/day and my 3.6 GB/day agree closely, and neither is addressed by83ff1c2.Thanks again for taking the time to measure this independently, and for being explicit about what your data does and does not support.
Correction to my figures (they were too low) and to one claim above
I re-derived everything from the raw measurement logs. Two things in what I posted need fixing.
1. The write rates were under-reported
| | reported | corrected |
| --- | ---: | ---: |
|
remote_plugin_catalog/<hash>.json| 65.1 GB/day | 91.6 GB/day ||
models_cache.json| 3.6 GB/day | 4.0 GB/day ||
codex_apps_tools/<hash>.json| 2.0 GB/day | 2.0 GB/day || total under
~/.codex/cache| 70.7 GB/day | 97.5 GB/day |Cause: I had two measurements of the same window and published the wrong one. The one I used reported 49 rewrites totalling 302 MB — which works out to 6.17 MB per rewrite against a file that is 7.95 MB. Since these are whole-file rewrites, per-rewrite bytes must equal the file size, so that run was missing events. The other log records every event individually with size and hash: 55 rewrites over 420 s, 424.5 MiB total, 8.09 MB per rewrite — consistent with the file size.
The lesson generalises to anyone measuring this: check
rewrites × file sizeagainst your byte total. If they disagree, your sampler is missing writes.The byte-identical fraction is unchanged — recomputed from the per-event log, 35 of 55 rewrites (63.6%), two distinct contents over the window.
The caveat I accepted from @FiniusPortalis still stands and matters more than the absolute number: this rate is a function of how often something calls
plugin/list, not a property of the catalog. My environment had 5–7 resident sessions polling every ~16 s.2. "The two keep each other permanently stale" was overstated
In my previous comment I wrote that a pre-TTL build sharing one
CODEX_HOMEkeeps the cache "permanently" stale. That is wrong as written.It only holds while a pre-TTL build is actually running. Once every component has the TTL, the condition clears itself: the first read after the old writer is gone refreshes once, writes
fetched_at, and the TTL works normally from then on. Updating every install path and restarting residentapp-serverprocesses is sufficient.It is worth noting only because no stable release has
83ff1c2yet, so anyone running the stable CLI alongside a TTL-carrying build is in that state until the stable release catches up. It is a migration-window issue, not a standing defect, and I should not have framed it as one.What I think still stands
fetched_at: Utc::now()embedding, which makes a serialized-bytes equality check ineffectivemodels_cache.jsonas a separate path not addressed by #34849, where the TTL renewal itself rewrites the whole file