Plugin catalog cache is rewritten in full after every `plugin/list` — no TTL, no content check, ~65 GB/day of disk writes

Open 💬 3 comments Opened Jul 25, 2026 by u-ichi

Summary

~/.codex/cache/remote_plugin_catalog/<hash>.json is rewritten in its entirety after every
plugin/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-server sharing the same CODEX_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-98RemotePluginCatalogDiskCache::write serializes

{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 answering plugin/list from 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-73models_cache.json does record client_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.json is 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

  1. 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.

  1. Add a TTL to the plugin catalog cache, as models_cache.json already has (300 s,

models-manager/src/manager.rs:26-27).

  1. Do not schedule a background refresh after every plugin/list when the cache was just

served and is within its TTL.

  1. **Either include client_version in 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 ioreg counter (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.sqlite write 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.

View original on GitHub ↗

3 Comments

FiniusPortalis · 1 month ago

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:

  • opening the standalone Codex CLI 0.145.0 TUI caused the remote plugin catalog to be rewritten multiple times;
  • two consecutive rewrites were byte-identical;
  • models_cache.json was also rewritten frequently and independently of the catalog;
  • the catalog rewrites occurred as a startup burst in my observation, not continuously for the full test window.

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

  • macOS 26.5.2 (Build 25F84), Darwin 25.5.0
  • MacBook Pro Mac16,8, Apple M4 Pro, 48 GB RAM, arm64
  • Standalone Codex CLI: 0.145.0 at ~/.local/bin/codex
  • Codex desktop bundle: 26.721.41059 (bundle build 5848)
  • Bundled Codex CLI: 0.146.0-alpha.3.1
  • Default shared CODEX_HOME: ~/.codex
  • Two bundled 0.146.0-alpha.3.1 app-server processes were already running
  • Remote catalog: 1,996 plugin entries
  • Remote catalog file size after refresh: 8,123,134 bytes

The standalone TUI was launched with:

codex --no-alt-screen -C ~/Projects/Codex

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:

  • timestamp;
  • file size;
  • SHA-256;
  • selected non-sensitive JSON metadata such as client_version and fetched_at.

The observer only read files and did not write to ~/.codex.

Two windows were measured:

  1. 90 seconds with the desktop app active and no standalone TUI.
  2. 80 seconds after starting the standalone Codex CLI 0.145.0 TUI.

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.json rate 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:

  • size: 8,118,659 bytes
  • SHA-256: c85219a3401d7689e4e2b92986044f3c1cdc811d527a51cfa078c345221877dc
  • fetched_at: absent / null

After 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.json observations

The model cache continued to change before and during the standalone TUI test:

  • 29 rewrites over 170 seconds;
  • resulting file size remained 304,142 bytes;
  • observed resulting client_version was 0.146.0;
  • fetched_at changed 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-server processes sharing one CODEX_HOME may be relevant, but this measurement does not establish causality.

The source path appears consistent with ModelsCacheManager::renew_cache_ttl updating fetched_at and save_internal rewriting the full JSON file.

Source/build note

I also confirmed that current main contains commit 83ff1c2, 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:

  1. plugin/list-related activity can cause repeated full rewrites of an 8+ MB remote catalog;
  2. byte-identical catalog rewrites occur in practice;
  3. models_cache.json has a separate frequent-rewrite problem worth tracking;
  4. multiple Codex processes/installations sharing CODEX_HOME deserve explicit coverage in regression tests.

It would be helpful to confirm:

  • which stable CLI/Desktop release will include the three-hour TTL behavior;
  • whether content-equality checks are planned for the catalog writer;
  • whether the model-cache TTL renewal path can avoid rewriting the full file;
  • whether multiple app-server processes sharing one CODEX_HOME are expected and tested.
u-ichi · 1 month ago

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-server processes plus an interactive TUI, and the rewrites in my window tracked the TUI's ~16-second plugin/list polling. Your run used a PTY that reported TERM=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 continuous plugin/list polling 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.5 and 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 bundled 0.146.0-alpha.3.1 was tagged later in wall-clock time (07-23 23:26 UTC) but branches off alpha.3, which is why it still wrote fetched_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_plugins serializes and writes unconditionally:

let Ok(contents) = serde_json::to_string_pretty(&RemotePluginCatalogDiskCache {
    schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION,
    fetched_at: Some(Utc::now()),
    plugins: plugins.to_vec(),
}) else {
    return;
};
let _ = codex_utils_path::write_atomically(&cache_path, &contents);

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 the plugins payload specifically, with fetched_at either excluded or refreshed in place.

A case where the TTL does not help: mixed versions sharing one CODEX_HOME

This follows from is_fresh:

fn is_fresh(fetched_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
    let Some(fetched_at) = fetched_at else {
        return false;
    };
    ...
}

A pre-TTL build (0.145.0 and earlier) writes an entry with no fetched_at. A post-TTL build reading that entry gets Stale every 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:

  • Homebrew (/opt/homebrew/bin/codex)
  • the ChatGPT desktop bundle
  • ~/.local/bin/codex

Updating one does not update the others, and app-server runs with --listen and 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

  1. The writer still rewrites the whole file regardless of content — the TTL reduces frequency but does not remove redundant writes (and forceRefetch bypasses the TTL entirely).
  2. Comparing serialized bytes will not work as written, because of the embedded fetched_at.
  3. Mixed pre-/post-TTL versions sharing a CODEX_HOME defeat the TTL completely.
  4. models_cache.json is a separate path — your 4.48 GB/day and my 3.6 GB/day agree closely, and neither is addressed by 83ff1c2.

Thanks again for taking the time to measure this independently, and for being explicit about what your data does and does not support.

u-ichi · 1 month ago

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 size against 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_HOME keeps 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 resident app-server processes is sufficient.

It is worth noting only because no stable release has 83ff1c2 yet, 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

  • Whole-file rewrites with no content comparison, ~64% of them byte-identical
  • The fetched_at: Utc::now() embedding, which makes a serialized-bytes equality check ineffective
  • models_cache.json as a separate path not addressed by #34849, where the TTL renewal itself rewrites the whole file