Skip to content

Fix flaky async_watcher_spec on macOS Ruby 3.2+#96

Merged
Watson1978 merged 1 commit into
socketry:mainfrom
Watson1978:fix/async-watcher-macos-eagain
Jun 28, 2026
Merged

Fix flaky async_watcher_spec on macOS Ruby 3.2+#96
Watson1978 merged 1 commit into
socketry:mainfrom
Watson1978:fix/async-watcher-macos-eagain

Conversation

@Watson1978

Copy link
Copy Markdown
Collaborator

Problem

spec/async_watcher_spec.rb fails intermittently on macOS with Ruby 3.2+.
The handshake on line 33 uses rd.sysread(1) to wait for each child to become ready:

nr_fork.times { expect(rd.sysread(1)).to eq('.') }

Root cause (macOS Ruby 3.2+, platform-conditional)

On macOS Ruby 3.2+, IO.pipe returns pipes with O_NONBLOCK already set on both ends:

rd F_GETFL = 0x4  (= O_NONBLOCK on macOS)
wr F_GETFL = 0x5  (= O_WRONLY | O_NONBLOCK)

This is unrelated to cool.io or libev — a bare IO.pipe call with no other code loaded reproduces it. sysread calls read() directly without any EAGAIN retry, so if the child has not yet written '.' when the parent reads, Errno::EAGAIN is raised.

Reproduced without cool.io using a minimal script (100 iterations × 2 reads):

Out of 200 sysread calls: 50 EAGAIN  (platform: arm64-darwin24, ruby: 3.2.6)

Linux is unaffected because IO.pipe there does not set O_NONBLOCK.

Fix

Replace sysread with read, which handles EAGAIN internally and waits transparently for data regardless of the fd's nonblocking flag.

# before
nr_fork.times { expect(rd.sysread(1)).to eq('.') }

# after
nr_fork.times { expect(rd.read(1)).to eq('.') }

Assertions (lines.size == nr_signal, uniq.size == nr_fork) are unchanged.

Verification (macOS / Ruby 3.2.6)

  • After fix: 0 failures out of 200 reads in the amplified race script
  • bundle exec rspec spec/async_watcher_spec.rb passed 10 consecutive runs
  • Full suite (bundle exec rspec spec/) passed: 44 examples, 0 failures

🤖 Generated with Claude Code

IO.pipe on macOS Ruby 3.2+ returns pipes with O_NONBLOCK set by
default. sysread(1) calls read() directly without EAGAIN retry,
so the handshake raises Errno::EAGAIN when the child has not yet
written before the parent reads. Replacing sysread with read lets
Ruby's I/O layer handle EAGAIN transparently and wait for data.

Reproduced without cool.io (25% EAGAIN rate in 200 attempts).
Linux is unaffected because IO.pipe there does not set O_NONBLOCK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Watson1978 Watson1978 merged commit c7fa506 into socketry:main Jun 28, 2026
14 checks passed
@Watson1978 Watson1978 deleted the fix/async-watcher-macos-eagain branch June 28, 2026 04:10
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