os/open: guard against tight loop in stderr streaming - #155
Conversation
The reader loop in openThread can spin without progress when takeDelimiterExclusive returns zero-length slices without signaling EndOfStream. Observed on macOS with Zig 0.15's std.Io.Reader at pipe EOF races: after the child (`/usr/bin/open URL`) exits, the stderr reader sits in a state where each takeDelimiterExclusive call returns an empty slice instead of throwing error.EndOfStream. The loop then emits `open stderr=` warnings at ~9,000/sec/thread and pins CPU indefinitely. Add a consecutive-empty-read counter that treats 16 empty reads in a row as EOF and exits the loop. Also move the child wait() into a `defer` so early breaks still reap the process. Reproduced in cmux (Ghostty 1.3.2-dev) — 5 leaked openThread instances produced ~45,000 empty warnings/sec, driving the parent process to 300-400% CPU with the log-flood surviving surface teardown. Introduced in bb375a2 ("deal with large outputs from xdg-open/rundll32/open"). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
📝 WalkthroughWalkthrough
ChangesopenThread I/O lifecycle
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR prevents runaway stderr logging in
Confidence Score: 4/5The PR appears safe to merge, with a non-blocking edge case where valid blank stderr records can leave a detached worker and child deadlocked. The runaway loop is bounded and child reaping is made unconditional, but the fixed record-count heuristic can stop draining a live child’s bounded stderr pipe before Files Needing Attention: src/os/open.zig Important Files Changed
Reviews (1): Last reviewed commit: "os/open: guard against tight loop in std..." | Re-trigger Greptile |
| }; | ||
| if (line.len == 0) { | ||
| empty_streak += 1; | ||
| if (empty_streak >= 16) break; |
There was a problem hiding this comment.
Blank records stop pipe draining
Sixteen consecutive blank stderr records terminate the drain loop without establishing EOF. A child that continues writing can then fill the bounded stderr pipe while exe.wait() waits for that child to exit, leaving the detached worker and child blocked; keep draining until EOF or close the pipe before waiting.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/os/open.zig`:
- Around line 80-101: The stderr loop in the open/execution flow incorrectly
treats 16 delimiter-separated blank lines as end-of-stream, potentially leaving
the child blocked on a full pipe. Update the logic around
reader.takeDelimiterExclusive('\n') so empty lines do not increment the
EOF/no-progress streak; only genuine zero-progress reads or terminal reader
errors should end draining. Add a regression test covering 16 blank lines
followed by substantial stderr output and verify the process completes without
blocking.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
Address review feedback (CodeRabbit, Greptile): the previous version treated 16 consecutive blank lines as end-of-stream, which could prematurely stop draining a child's stderr pipe if it happened to emit that many blank records in a row and potentially deadlock the child on a full pipe buffer. Use reader.peek(1) after any zero-length line to distinguish a real blank record from a truly-terminated reader. peek() only fails at real EOF (or read error), so blank lines never trigger an early exit while the reader is still producing. Keep a large no_progress counter (4096) as a safety net against a pathologically-buggy reader that neither errors nor makes progress across many iterations; this should never trigger in practice but caps the damage of any future std.Io regression. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address downstream review feedback (CodeRabbit, Greptile) on the mirror patch in manaflow-ai#155: the previous version treated 16 consecutive blank lines as end-of-stream, which could prematurely stop draining a child's stderr pipe if it happened to emit that many blank records in a row and potentially deadlock the child on a full pipe buffer. Use reader.peek(1) after any zero-length line to distinguish a real blank record from a truly-terminated reader. peek() only fails at real EOF (or read error), so blank lines never trigger an early exit while the reader is still producing. Keep a large no_progress counter (4096) as a safety net against a pathologically-buggy reader that neither errors nor makes progress across many iterations; this should never trigger in practice but caps the damage of any future std.Io regression. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the review, @coderabbitai and @greptile-apps — the concern is valid. Pushed fb2ea98d to address it:
The upstream mirror (ghostty-org/ghostty#13480) has been updated with the same fix on the |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline src/os/open.zig --items all --match 'openThread'
rg -n -C 18 'no_progress|empty_streak|takeDelimiterExclusive|\.peek\s*\(|openThread' src/os/open.zig
git diff --find-renames HEAD^ HEAD -- src/os/open.zigLength of output: 5436
One remaining edge case: Please reset/remove the counter after a successful 🐇 |
|
The updated approach is solid. Using A few notes on the current implementation:
Overall this is a meaningfully better fix than the original streak counter. The logic is sound. Tip: You can customize Greptile's behavior for this repo with |
Summary
Fixes a runaway loop in
openThreadthat spams tens of thousands of emptyopen stderr=log warnings per second and pins the parent process at 300%+ CPU indefinitely.Reproducer (cmux 1.3.x, Ghostty 1.3.2-dev, macOS)
Observed on a running cmux instance:
[com.mitchellh.ghostty:os-open] os-open: open stderr=(empty payload)log stream --process cmuxreportsMessages dropped during live streaming— flood exceedsos_logthroughputsample <pid>top-of-stack was dominated byos_log_impl_flatten_and_send,os_log_create,_os_log, andzig_os_log_with_type, confirming the hot path is thelog.warninsideopenThread.Root cause
Introduced in bb375a2 ("deal with large outputs from xdg-open/rundll32/open"), which replaced the batched
collectOutputwith a streaming reader loop.Under Zig 0.15.2's new
std.Io.Reader,takeDelimiterExclusive('\n')can return zero-length slices without throwingEndOfStreamwhen the underlying pipe has reached EOF but the reader has not yet fully drained its state. On macOS, once/usr/bin/open <url>exits, its stderr pipe enters this state and every subsequent call returns an empty slice — no error variant of the outerswitchmatches, so the loop neverbreaks andlog.warn("open stderr={s}", .{line})fires every iteration with an empty string.Because
openThreadis.detach()'d, each leaked instance runs forever. The five leaks in the reproducer corresponded to five prioropen()calls over the lifetime of the process.A secondary issue: the pre-patch code calls
_ = exe.wait() catch {};after the stderr block, so any of the existingbreakpaths that leave the loop early skip the reap.Fix
open/xdg-open/rundll32but future-proof) while providing a hard ceiling that prevents the tight loop.defer exe.wait(): unconditional reap regardless of which branch exits the loop.Verification
openThreads was pinning 3–4 cores at 100 % and floodingos_logat ~45k lines/sec; after applying this patch and rebuilding, CPU is idle and noos-openmessages are emitted after each child exits.EndOfStream).Notes
ghostty-org/ghosttymainbranch carries the same bb375a2, so this fix applies there as well; happy to open a parallel PR upstream if desired.takeDelimiterExclusivereturning an empty slice at EOF instead ofEndOfStreammay itself be astd.Io.Readerbug worth reporting to Zig, but a defensive guard here is the right call regardless.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Prevents a tight loop in
openThreadstderr streaming that spammed empty logs and pegged CPU after/usr/bin/openexited. Usespeek()to detect EOF on blank reads and always reaps the child process.reader.peek(1)and only break on EOF/read error; add a 4096 no-progress cap as a safety net.exe.wait()todeferso the child is reaped on all exits.Written for commit fb2ea98. Summary will update on new commits.
Summary by CodeRabbit