login: get_codex_user_agent() re-runs os_info::get() per HTTP client build, spawning lsb_release/dpkg-query/getconf (~27 ms each on Linux)

Open 💬 2 comments Opened Jul 30, 2026 by devin-ai-integration[bot]

Summary

get_codex_user_agent() (codex-rs/login/src/auth/default_client.rs) calls os_info::get() on every invocation, and it is called from default_headers(), which runs on every default HTTP client build:

pub fn get_codex_user_agent() -> String {
    let build_version = env!("CARGO_PKG_VERSION");
    let os_info = os_info::get();      // <- spawns subprocesses on Linux, every call
    ...
}

pub fn default_headers() -> HeaderMap {
    ...
    if let Ok(user_agent) = HeaderValue::from_str(&get_codex_user_agent()) {

On Linux, os_info::get() shells out. Verified with strace -f -e trace=execve against os_info 3.14.0 (the version in codex-rs/Cargo.lock):

execve("/usr/bin/lsb_release", ["lsb_release", "-a"], ...) = 0
execve("/usr/bin/dpkg-query", ["dpkg-query", "-f", "${Version} ${Provides}\n", "-W", "lsb-core", ...]) = 0
execve("/usr/bin/getconf", ["getconf", "LONG_BIT"], ...) = 0

lsb_release is a Python script on Debian/Ubuntu (#!/usr/bin/python3 -Es), so each call pays a Python interpreter startup plus a dpkg-query package scan. There were also ~45 failed execve attempts walking PATH before lsb_release resolved.

Measured cost on Ubuntu 24.04 x86-64 (release build, 50 iterations, warm cache):

os_info::get(): ~27 ms per call

The result is immutable for the process lifetime, and sibling values in the same module (ORIGINATOR, REQUIREMENTS_RESIDENCY, USER_AGENT_SUFFIX) are already cached in statics — the OS probe is the one that is not.

Why this matters

default_headers() is on paths that run repeatedly, not once at startup:

  • default_http_client_builder()create_client() / create_client_for_route(), so every default client build re-probes the OS
  • codex-rs/chatgpt/src/chatgpt_client.rs calls create_client() per request
  • codex-rs/analytics/src/client.rs calls create_client() per analytics upload
  • codex-rs/core/src/client.rs::build_api_transport builds a client per compaction / realtime / memories call, and connect_websocket passes default_headers() on every websocket connect (prewarm and every reconnect)
  • codex-rs/core-plugins/src/{remote.rs,startup_sync.rs} and codex-rs/tui/src/updates.rs also call it per request

So a workload with many short-lived threads/turns spawns 3 subprocesses and burns ~27 ms of wall time (mostly CPU across the process tree) per client build, purely to re-derive a constant string. This is closely related to #29369 (fresh reqwest::Client per request) — the same call site is hot for both reasons — but caching the client and caching the OS probe are independent fixes, and the OS probe is the more expensive one on Linux.

Secondary concern: spawning lsb_release/dpkg-query per request is noisy in sandboxed and audited environments (process-exec auditing, seccomp/sandbox policies, container images that intentionally omit lsb_release), and it is a per-request dependency on the host having those binaries.

Production impact (downstream harness)

We run codex app-server headless on Linux x86-64 as an agent fleet (many short-lived threads/turns per process). On tag rust-v0.146.0, strace showed lsb_releasedpkg-query spawns on the per-turn client-build path, and caching the user-agent in a static LazyLock<String> was one of two patches (the other being a shared TLS root store, filed separately) that took us from 0.36–0.39 CPU-seconds per turn to 0.15 CPU-s/turn on real authenticated turns. Those numbers cover both patches plus disabling unused features, so treat them as the aggregate; the isolated cost of this path is the ~27 ms + 3 subprocesses per call measured above.

Proposed fix

Cache the whole user-agent (or at minimum the OS fragment) in a process-level static, matching how ORIGINATOR is already handled:

static USER_AGENT_OS_FRAGMENT: LazyLock<String> = LazyLock::new(|| {
    let info = os_info::get();
    format!("{} {}; {}", info.os_type(), info.version(), info.architecture().unwrap_or("unknown"))
});

The mutable USER_AGENT_SUFFIX still needs to be read per call, so caching the OS fragment (rather than the full string) keeps current behavior exactly while removing the subprocess spawns. Happy to send a PR.

Environment

  • Repo state inspected: main @ 0042b00986b9cc73c82c93f94e93d747818228be
  • os_info 3.14.0 (per codex-rs/Cargo.lock), measured standalone at that version
  • Linux x86-64 (Ubuntu 24.04), headless codex app-server via JSON-RPC (no TUI)

View original on GitHub ↗

2 Comments

zolinthecow · 28 days ago

Oh man sorry I had Devin see if it could find existing issues for this and https://github.com/openai/codex/issues/36209 but it somehow went and filed the issues itself and apparently cannot close it.

The issues themselves do still apply though, we found them and had Devin patch them and it works.

faberix76 · 22 days ago

Additional data point for this call path, measured on a Desktop session on Linux that was completely idle. The per-call cost described here compounds into ~280 subprocesses/second and a sustained 228% CPU in codex app-server with no active thread or turn, so I thought the numbers were worth adding.

Environment and disclosure

  • Codex CLI (the app-server core, official npm @openai/codex): 0.144.6
  • Desktop app 26.730.61639, Electron 42.3.0
  • Ubuntu 26.04 LTS, x86-64, kernel 7.0.0, 32 cores, GNOME, Wayland session with the app on XWayland (--ozone-platform=x11)
  • Disclosure: the Desktop app here is the community Linux repack of the macOS DMG (ilysenko/codex-desktop-linux), not an official Linux build. The hot path measured below is in the official npm CLI core, which is why I'm posting it here. See the caveat at the end about what I can't attribute cleanly.

Confirmation of the mechanism, with one refinement

strace -f -e trace=execve on the app-server core, 4 s window, idle session — the two probes from os_info::get() dominate, each preceded by a failed walk of a 27-entry PATH:

 93 execve("<HOME>/.local/bin/lsb_release", ["lsb_release", "-a"]
 93 execve("<HOME>/.local/bin/getconf",     ["getconf", "LONG_BIT"]
 62 execve("/usr/bin/tr",  ["tr", "[:upper:]...
 31 execve("/usr/bin/getopt", ["getopt", "--name", "lsb_release", "-o", "hvidrcas", ...
 31 execve("/usr/bin/cut", ["cut", "-c1"]
 31 execve("/usr/bin/cut", ["cut", "-c2-"]
 31 execve("/usr/bin/lsb_release", ["lsb_release", "-a"]
 31 execve("/usr/bin/getconf", ["getconf", "LONG_BIT"]
      (plus the same two names attempted across every other PATH entry)

Refinement to the original report: on Ubuntu 26.04 /usr/bin/lsb_release is #!/bin/sh, not #!/usr/bin/python3 -Es as on 24.04. So there is no Python interpreter startup here, and I saw no dpkg-query — but the shell implementation instead forks getopt, tr and cut (3–4 extra processes per call), so the total process count per os_info::get() is comparable. The failed-PATH-walk amplification you noted is the larger effect: with 27 PATH entries, resolving each of the two binaries costs ~26 failed execve first.

Aggregate syscall cost

strace -f -c on the same idle core, 8 s window:

| syscall | calls | errors |
|---|---:|---:|
| futex | 694,587 | — |
| mmap / munmap | 51,517 / 43,778 | — |
| openat | 7,640 | 3,892 |
| newfstatat | 7,344 | 6,372 |
| execve | 2,270 | 1,836 |
| clone | 869 | — |

That is ~284 execve/s with an 81% failure rate, and futex accounting for ~89% of syscall time — i.e. the thread churn from process creation costs more than the probes themselves.

What drives it this hard at idle

The Desktop renderers poll the core with app/list + app/installed (~5 requests/s combined, at idle, and it doubles with a second window open). From the core's own log (~/.codex/logs_2.sqlite), one 60 s idle window:

| event | count / 60 s |
|---|---:|
| app/list requests | 152 |
| app/installed requests | 148 |
| codex_apps MCP server initialize | 72 |
| POST to chatgpt.com (exec_server.http_request) | 237 |
| custom_ca "using system root certificates" (new HTTP client built) | 260 |

Nothing appears to be cached across requests: each round re-initializes the remote codex_apps MCP server and builds a fresh HTTP client — which is where default_headers()get_codex_user_agent()os_info::get() gets hit ~4×/s, producing the process storm above. This is exactly the #29369 interaction you referenced: the two issues multiply each other here rather than being independent.

Totals: 228% CPU in app-server, ~362% across all Codex processes, fully idle (verified no row in threads in state_5.sqlite had a recent updated_at_ms).

Mitigation that confirms the chain

Setting apps = false under [features] in ~/.codex/config.toml and restarting takes every counter above to 0 and total Codex CPU from 362% → 2.2%, with execve dropping to 0 and clone to 1 per 6 s. So the caching fix proposed in this issue would remove the per-call cost, and the uncached-client/uncached-app-list behaviour is what turns it into a fan-spinning idle loop.

Caveat on attribution

I can attribute the per-call cost to the official CLI core with confidence — that's this issue's call site. I cannot cleanly attribute the trigger (the app/list/app/installed polling and per-request MCP re-init) to upstream versus the Linux repack, because that project applies three patches touching app-server behaviour: linux-app-server-feature-enablement, linux-local-app-server-feature-enablement-handler, linux-app-server-backfill-wait. I have not tested whether the same polling rate occurs on macOS or Windows. Worth noting the same shape is reported on other platforms — #34929 (Windows, ~12–15 taskkill.exe/s and 40–50% idle CPU), #31499 (Windows, duplicate MCP stdio pools), #34901 (app/list pushing the full catalog) — which suggests the polling side is not Linux-specific, but I'd rather flag the uncertainty than overstate it.

Happy to run further measurements or an A/B on request.