Skip to content

os/open: guard against tight loop in stderr streaming - #13480

Closed
chyroc wants to merge 1 commit into
ghostty-org:mainfrom
chyroc:fix/os-open-tight-loop-upstream
Closed

os/open: guard against tight loop in stderr streaming#13480
chyroc wants to merge 1 commit into
ghostty-org:mainfrom
chyroc:fix/os-open-tight-loop-upstream

Conversation

@chyroc

@chyroc chyroc commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Fixes a runaway loop in openThread that spams tens of thousands of empty open stderr= log warnings per second and pins the parent process at 300%+ CPU indefinitely.

Reproducer (macOS)

Observed downstream in cmux (which vendors Ghostty via a fork), against Ghostty 1.3.2-dev:

  • ~45,000 log lines/sec all reading [com.mitchellh.ghostty:os-open] os-open: open stderr= (empty payload)
  • log stream --process cmux reports Messages dropped during live streaming — flood exceeds os_log throughput
  • Parent process at 300–400 % CPU across 5 threads, each producing ~9,000 lines/sec
  • Log-flood survives surface / workspace teardown; only killing the process stops it

sample <pid> top-of-stack was dominated by os_log_impl_flatten_and_send, os_log_create, _os_log, and zig_os_log_with_type, confirming the hot path is the log.warn inside openThread.

Root cause

Introduced in bb375a2 ("deal with large outputs from xdg-open/rundll32/open"), which replaced the batched collectOutput with a streaming reader loop.

takeDelimiterExclusive('\n') on std.Io.Reader can return zero-length slices without throwing EndOfStream when 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 outer switch matches, so the loop never breaks and log.warn("open stderr={s}", .{line}) fires every iteration with an empty string.

Because openThread is .detach()'d, each leaked instance runs forever. The five leaks in the reproducer corresponded to five prior open() calls over the lifetime of the process.

A secondary issue: the pre-patch code calls _ = exe.wait(io) catch {}; after the stderr block, so any of the existing break paths that leave the loop early skip the reap.

Fix

  1. Empty-read guard: count consecutive zero-length lines and break after 16. This tolerates legitimate blank lines in child output (unlikely from open/xdg-open/rundll32 but future-proof) while providing a hard ceiling that prevents the tight loop.
  2. defer exe.wait(): unconditional reap regardless of which branch exits the loop.
  3. Coalesce identical error arms in the switch for readability (no behavior change).

Verification

  • Manual repro downstream in cmux: 5 leaked openThreads were pinning 3–4 cores at 100 % and flooding os_log at ~45k lines/sec; after applying this patch and rebuilding, CPU is idle and no os-open messages are emitted after each child exits.
  • No behavioral change on the happy path (child writes finite stderr, terminates, loop exits via EndOfStream).

Notes

  • takeDelimiterExclusive returning an empty slice at EOF instead of EndOfStream may itself be a std.Io.Reader bug worth reporting to Zig upstream, but a defensive guard here is the right call regardless.
  • A downstream copy of this patch has also been proposed to the manaflow-ai fork used by cmux (manaflow-ai/ghostty#155).

🤖 Generated with Claude Code

The reader loop in openThread can spin without progress when
takeDelimiterExclusive returns zero-length slices without signaling
EndOfStream. Observed on macOS with Zig'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 downstream in cmux (which vendors Ghostty) — 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>
@ghostty-vouch

ghostty-vouch Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Hi @chyroc, thanks for your interest in contributing!

This project requires that pull request authors are vouched, and you are not in the list of vouched users.

This PR will be closed automatically. See https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md for more details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant