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 Beginner friendly
#36,210 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
72/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
rust

Research direction

Start in codex-rs/login/src/auth/default_client.rs at get_codex_user_agent() and default_headers(), then inspect the existing cached statics in that module. Verify that the OS probe is performed once per process while USER_AGENT_SUFFIX is still reflected per call, and confirm the repeated client-build paths no longer spawn the listed Linux subprocesses.

Written by the indexing model from the issue text.

Description

bug CLI performance

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)
Dominant language
Rust
Stars
125k
Forks
19.5k
Avg merge
1m
Merged PRs (30d)
1k

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from openai/codex

All issues in openai/codex

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.