Exit 0, Empty stdout: the Quota Died on stderr

작성자

카테고리:

← 피드로
DEV Community · John · 2026-08-18 개발(SW)

Originally published on hexisteme notes.

The setup: workers as ephemeral subprocesses

Part of how I run background coding tasks is by shelling out to a subscription-gated CLI from a different vendor than my main assistant, one task at a time. No daemon, no shared server — each worker starts, does one job, and exits, and I read back whatever it produced. That pattern itself is fine.

What I got wrong for a while was how I decided whether a worker had actually done anything.

The failure: exit 0, nothing on stdout

On 2026-08-07, one of these workers — OpenAI’s Codex CLI, invoked non-interactively — hit its own usage cap mid-task. The real failure message was:

ERROR: You've hit your usage limit

Enter fullscreen mode Exit fullscreen mode

printed to stderr. Meanwhile:

  • stdout was 0 bytes — no output, no partial result, nothing.
  • the exit code was 0.

If a dispatcher only checks $?, this looks identical to a worker that quietly finished a trivial task and had nothing to say. There’s no crash, no nonzero status, no exception to catch anywhere in the normal control flow. The failure is completely real; it’s just filed under the wrong file descriptor, and the exit code actively lies about it.

Why this is worse than an ordinary silent failure

A tool that fails loudly — nonzero exit, a stack trace, a panic: line — is annoying but honest: an if $? -ne 0 branch catches it whether or not you anticipated the specific failure mode. This is a different shape of problem. The interface contract the orchestration is trusting — exit code as the success/failure signal — stays green, while the actual work product (stdout) is empty. Anyone who wires “exit code equals 0” to “mark the task done, move on” will silently record a quota death as a completed job.

A compounding trap: macOS doesn’t ship timeout

It gets worse on macOS specifically: there’s no timeout binary by default, so wrapping a worker call in timeout ... on a machine where it doesn’t resolve hands back a shell “command not found” as exit 0 — a second, independent path to the same false-success signal.

Not a one-vendor quirk

I’d already tripped over a version of this once before, with a different vendor’s CLI, and filed it away as “that tool is just weird about how it reports quota.” Watching the identical shape — real error on stderr, empty stdout, exit 0 — show up in a completely separate CLI from a completely different vendor changed the diagnosis: this isn’t a bug in one wrapper, it’s how subscription-gated command-line tools tend to communicate “you’re out of quota.” They treat it as a billing condition rather than a program error, so the message goes to stderr and the process exits cleanly rather than breaking a caller’s shell pipeline with a nonzero status.

Once that’s the assumption, you stop trusting exit codes from any subscription CLI by default.

The fix: judge the artifact, not the exit code

The rule I apply to every worker dispatch now is boring and mechanical, which is the point:

  1. Exit code is advisory, not authoritative. A 0 means the process didn’t crash. It says nothing about whether it produced anything.
  2. Empty stdout is a failure, independent of exit code. If the contract is “the worker prints its result to stdout,” zero bytes there is a hard fail, full stop.
  3. Check for the expected artifact, not the worker’s own claims about it. If it was supposed to write a file, look for that file at the path you expected, with content that resembles what you asked for.
  4. Read stderr even when stdout looks fine and the exit code is 0. The actual diagnostic here was sitting one file descriptor away from where the dispatcher was looking.
  5. Verify your own wrapper’s dependencies before trusting its exit code. If a dispatch script assumes a binary like timeout exists on every machine it runs on, that assumption is itself a failure mode to check for — not just the worker’s behavior.

None of this requires knowing anything vendor-specific ahead of time. It requires treating “exit 0” as one weak signal among several, not the whole verdict.

Where the same shape shows up outside AI tooling

This isn’t specific to LLM CLIs — any pipeline-friendly tool tends to swallow certain failure classes into stderr-plus-exit-0 rather than a hard nonzero exit, because tool authors don’t want a quota, rate-limit, or auth condition to break a caller’s pipeline. The fix generalizes too: treat the exit code of anything you didn’t write as a hint, and check the actual output before marking a step done.

Related failure, opposite direction

I’ve also hit the mirror-image bug elsewhere — a wrapper failing while the underlying capability still works (a false negative); this one is the opposite: dead on arrival, but the harness says yes (a false positive).

The same distrust-the-green-light instinct applies at the HTTP layer — I’ve separately seen a 200 OK with an error payload inside get cached as if it were good data; same root cause, different transport.

More notes at hexisteme.github.io/notes.

원문에서 계속 ↗