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)
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 OScodex-rs/chatgpt/src/chatgpt_client.rscallscreate_client()per requestcodex-rs/analytics/src/client.rscallscreate_client()per analytics uploadcodex-rs/core/src/client.rs::build_api_transportbuilds a client per compaction / realtime / memories call, andconnect_websocketpassesdefault_headers()on every websocket connect (prewarm and every reconnect)codex-rs/core-plugins/src/{remote.rs,startup_sync.rs}andcodex-rs/tui/src/updates.rsalso 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_release → dpkg-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_info3.14.0 (percodex-rs/Cargo.lock), measured standalone at that version- Linux x86-64 (Ubuntu 24.04), headless
codex app-servervia JSON-RPC (no TUI)
2 Comments
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.
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-serverwith no active thread or turn, so I thought the numbers were worth adding.Environment and disclosure
app-servercore, official npm@openai/codex): 0.144.6--ozone-platform=x11)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=execveon theapp-servercore, 4 s window, idle session — the two probes fromos_info::get()dominate, each preceded by a failed walk of a 27-entryPATH:Refinement to the original report: on Ubuntu 26.04
/usr/bin/lsb_releaseis#!/bin/sh, not#!/usr/bin/python3 -Esas on 24.04. So there is no Python interpreter startup here, and I saw nodpkg-query— but the shell implementation instead forksgetopt,trandcut(3–4 extra processes per call), so the total process count peros_info::get()is comparable. The failed-PATH-walk amplification you noted is the larger effect: with 27PATHentries, resolving each of the two binaries costs ~26 failedexecvefirst.Aggregate syscall cost
strace -f -con 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, andfutexaccounting 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/listrequests | 152 ||
app/installedrequests | 148 ||
codex_appsMCP server initialize | 72 ||
POSTtochatgpt.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_appsMCP server and builds a fresh HTTP client — which is wheredefault_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 inthreadsinstate_5.sqlitehad a recentupdated_at_ms).Mitigation that confirms the chain
Setting
apps = falseunder[features]in~/.codex/config.tomland restarting takes every counter above to 0 and total Codex CPU from 362% → 2.2%, withexecvedropping to 0 andcloneto 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/installedpolling 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–15taskkill.exe/s and 40–50% idle CPU), #31499 (Windows, duplicate MCP stdio pools), #34901 (app/listpushing 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.