Fine in the terminal, hung in the app: nobody read its stdout
Scenri's first Codex engine froze on every picture, while the same command finished in a terminal. The command was fine. Nobody was reading what it printed.

In this story5 sections
The first Codex engine in Scenri had one job: run codex exec, wait for it to draw a picture, and pick up the file it left behind. In a terminal, the command finished and the picture appeared. Inside Scenri, the same command with the same arguments sat there until the timeout killed it. Every time.
The command was not the problem. The problem was who was listening.
A pipe is a bucket with a lid
When you run a program in a terminal, its output goes to the terminal, and the terminal reads everything as fast as it arrives. When your app spawns the same program as a child process, its output goes into a pipe instead, and the pipe holds only so much. Scenri's code puts the number in a comment: 64 KB.
Codex prints its whole transcript to stdout while it works. Scenri never used that transcript. The answer is the file Codex writes, so stdout looked like noise, and nothing was attached to it.
So the transcript filled the pipe. Once the pipe is full, the next write blocks, and the writer waits until someone reads. Nobody would. Codex sat mid-sentence, the picture was never drawn, and from the outside it looked exactly like a slow model.
That is why the terminal was a false witness. The terminal drains the output as a side effect of showing it to you, so the bug cannot appear there.
The fix is one line, and it shipped in 0.1.0
The first public version of Scenri, committed on 4 August 2026, already carried the fix and the reason for it:
// codex streams its full transcript to stdout; it MUST be drained or the// 64KB pipe buffer fills and codex blocks forever (real hang, 2026-08-01).child.stdout?.on('data', () => {});A listener that does nothing is still a reader. The pipe empties, Codex keeps writing, and the run finishes.
It came with a regression test that uses a real child process and real pipes, not a mock. The child writes 20 blocks of 64 KB to stdout with synchronous writes, which block the way Codex's do when nobody reads, and only then writes its picture. The test passes if the engine gets the picture back within an 8 second budget. Remove the drain and it times out.
The same rule spread to every other place Scenri starts Codex. The setup command that installs and signs in carries its own one line note: drained for the same reason every other Codex pipe is, a full buffer hangs.
Draining is not the same as discarding
The first fix threw the output away. Four weeks later that cost something.
A tester on Windows had a picture fail, and the only error Scenri showed was Codex's own startup banner: version, working folder, model. The commit from 31 August explains why. Scenri kept the first 200 characters of stderr as the failure message, and the banner alone is about 202 bytes. The line that said what went wrong came later. Stdout, where Codex narrates its failures, was being drained into nothing.
Now both pipes keep a rolling tail of 4,096 bytes. The failure message strips the banner and reports the end of what is left, preferring Codex's own ERROR: line, because the reason a run ended is always at its end.
child.stdout?.on('data', (d) => { stdout = keepTail(stdout, String(d)); sawActivity();});A tail, not a transcript: bounded, so a long run cannot grow it.
Silence is not the same as stuck
The other lesson came from the same pipes. To catch a wedged process, Scenri once killed any run that went 120 seconds without printing. The commit from 29 August records what that did: the image tool call inside Codex is one quiet network round trip that regularly takes longer than two minutes, so healthy generations were being killed about one time in eight.
The watchdog is now one shot. A healthy run prints within seconds of launch; a spawn that wedges never prints at all. So Scenri waits up to 60 seconds for the first byte on either pipe, and the moment one arrives the guard is switched off for good. After that, only the hard cap of 300 seconds applies. The same commit made a kill on macOS and Linux take the whole process group, so a killed Codex cannot leave its own children running.
Every run also logs the time to first byte, the longest silence and the total, so the next timeout value can be chosen from evidence instead of a guess.
- Pipe buffer, per the code's comment
- 64 KB
- Healthy runs the old watchdog killed
- 1 in 8
- Tail kept from each pipe for the error
- 4,096 bytes
- First-byte wait, then never again
- 60 s
When a child process works in a terminal but not in your app
- Ask who reads its stdout and its stderr. If the answer is nobody, that is your hang.
- Attach a reader to every pipe you open, even one whose content you do not need. Or open it as ignored, if you truly never need it.
- Keep a bounded tail of both pipes. The reason for a failure is at the end, and you will want it.
- Guard against a process that never starts, not against one that goes quiet. Quiet can be work.
- Reproduce with real pipes in a test. A mocked child cannot fill a buffer.



