Skip to content

Read codex app-server replies unbuffered in the usage collector - #13102

Closed
surim0n wants to merge 1 commit into
omacom:quattrofrom
surim0n:fix/codex-limits-rpc
Closed

surim0n wants to merge 1 commit into
omacom:quattrofrom
surim0n:fix/codex-limits-rpc

Conversation

@surim0n

@surim0n surim0n commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • rpc_request() waited with select() on the pipe fd, then read through proc.stdout.readline() — a buffered text stream
  • When the app-server chunked a notification (remoteControl/status/changed, account/updated) into the same read as a reply, readline() pulled the whole chunk into Python's buffer and returned only the first line; the reply was already buffered so the fd never became readable again, select() kept timing out, and account/read intermittently raised TimeoutError (~1 in 4 runs in the issue's repro)
  • New RpcLineReader reads the fd raw via os.read and splits lines itself; the buffer persists across rpc_request() calls since a reply can arrive together with the previous one

Also makes the scanner test's mapfile/stat -c/touch -d usage portable so the suite runs off GNU userland.

Fixes #13080

Test plan

  • bash test/shell.d/agent-usage-codex-scanner-test.sh — new regression test stubs codex app-server to emit a notification and the reply in a single write; fails on the old code (TimeoutError), passes on the fix. All existing assertions pass.
  • for i in $(seq 8); do omarchy-agent-usage-codex --limits-only | jq -r .usageStatusText; done no longer shows Codex limits unavailable

rpc_request() selected on the pipe fd but read through buffered stdio.
When the app-server chunked a notification together with a reply, the
reply sat inside Python's buffer while select() waited on an fd that
would never be readable again, so account/read intermittently timed out
and the agents widget showed "Codex limits unavailable" until the next
refresh happened to win.

Add RpcLineReader, which reads the fd raw and splits lines itself, with
a buffer that persists across requests since replies can arrive
together. Also make the scanner test's mapfile/stat/touch -d usage
portable so the suite runs off GNU userland.

Fixes omacom#13080
@llstrk

llstrk commented Sep 25, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

No issue found in the collector change within the tested scope. One optional note concerns the new regression test.

Verified: the change fixes the stalled reply described in #13080. In Codex rust-v0.156.1, the stdio transport writes each JSON-RPC message as its own newline-terminated write. After initialize, a separately spawned task sends account/updated, so a notification can land in the same pipe read as a reply. The old loop called select() on the fd and then the buffered proc.stdout.readline(). That read could pull both lines into Python's buffer and return only the first, so the reply was no longer visible to the next select(). RpcLineReader checks its own buffer before selecting and keeps leftover bytes across requests.

The base and head fetch_codex_rpc() were each run against a scripted synthetic codex stub:

Scripted server behavior Base PR head
Notification and reply in one write Times out (initialize, 8 s) Reply read
account/updated and reply in one write Times out (account/read, 4 s) Reply read
Partial notification line carried into the next request Times out (account/read) Reply read
Reply split mid-JSON or mid-UTF-8 character, CRLF endings Reply read Reply read
Byte trickle with no newline for 7 s Returns after 7.0 s (4 s deadline overrun) Returns at the 4 s deadline
Invalid UTF-8 line before the reply Fetch aborts (UnicodeDecodeError) Line skipped, reply read

Verified: with the head test file, the full scanner test file passes against the head collector. Against the base collector it fails only on the new test. The error paths and process cleanup in fetch_codex_rpc() are unchanged. The touch -d and stat -c replacements behave the same as the commands they replace.

Optional test improvement: the new stub's comment says the notification and reply go out in "one write". Bash's printf actually makes two write() calls here, one per line, because Bash line-buffers stdout. Whether the base collector reads both lines at once therefore depends on scheduling. The base collector failed on every run of this test here. However, a stub that made separate back-to-back writes (as Codex does) passed on base 10 of 10 times when pinned to one CPU. With each pair emitted from a single jq call, the stub makes one write per response by construction. With that stub, base failed 10 of 10 times (including pinned) and head passed 20 of 20:

    initialize)
      jq -cn --argjson id "$id" '{method: "remoteControl/status/changed", params: {}}, {id: $id, result: {}}'
      ;;
    account/read)
      jq -cn --argjson id "$id" '{method: "account/updated", params: {}}, {id: $id, result: {account: {type: "plus"}}}'
      ;;

Review information

Test scope: Source review of the changed collector and test, the Codex rust-v0.156.1 app-server transport and connection setup, and Bash 5.3 printf and stdout buffering. Isolated command-only runs used synthetic codex stubs against the base and head collectors, and ran the repository scanner test file. The real Codex app-server was not run, and no live account was used. The effect on a live system rests on the Codex source and the scripted reproductions. The test file was run on Linux with GNU userland only.

AI process: Opus 5.5 Medium coordination and synthesis, independent Opus 5.5 Xhigh and GPT 6 Sol Xhigh technical assessments, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

@vitonique

Copy link
Copy Markdown

Tested this PR against a live Codex login and it fixes the failure for me. Data in case it helps the merge decision:

Environment: Omarchy 4.0.4-1, kernel 7.2.5-3-omarchy, codex-cli 0.157.0 (same result on 0.156.1), ChatGPT login (prolite plan).

Stock collector (/usr/bin/omarchy-agent-usage-codex --limits-only --force), 5 runs: 5/5 return usageStatusText: "Codex limits unavailable", authHelpText: "account/read", limits: [].

Collector from this PR (git show pr-13102:bin/omarchy-agent-usage-codex), 5 runs: 5/5 return limits: [1 window], tierLabel: "prolite".

Independent probe of the race described in #13080 (same RPC sequence: initialize → initialized → account/read, fresh codex -s read-only -a on-request app-server per run):

reader account/read params result
buffered readline() + select() (as in the current collector) {} 2 of 3 runs time out (10 s)
buffered readline() + select() {"refreshToken": false} 2 of 3 runs time out
os.read() + manual line splitting {} 6 of 6 succeed in 0.3–0.7 s

In every run the app-server emits remoteControl/status/changed and account/updated notifications right before the reply, which is exactly the coalesced-chunk scenario the PR's RpcLineReader handles. The request params make no difference, so the fix is in the right place. account/rateLimits/read alone answers in ~0.6 s.

— vitomarchy agent (Claude Code session, measurements reproducible with the scripts above; happy to share them)

@surim0n

surim0n commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the live measurements, that is exactly the coalesced-chunk case (remoteControl/status/changed + account/updated arriving in the same read as the reply) the unbuffered reader is for. Good to have it confirmed against 0.156.1 and 0.157.0 with a real login.

@dhh

dhh commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Thanks! The same fix is coming in through #14049 (from #13703). Closing in favor of that.

@dhh dhh closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agents widget: Codex limits intermittently fail with "account/read" (select/readline race)

5 participants