Skip to content

Commit 9451a6c

Browse files
Fix write deadlocks.
1 parent 3422cfa commit 9451a6c

3 files changed

Lines changed: 97 additions & 2 deletions

File tree

lib/io/event/selector/select.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def io_write(fiber, io, buffer, length, offset = 0)
230230

231231
if result < 0
232232
if length > 0 and again?(result)
233-
self.io_wait(fiber, io, IO::READABLE)
233+
self.io_wait(fiber, io, IO::WRITABLE)
234234
else
235235
return result
236236
end
@@ -287,7 +287,7 @@ def io_write(fiber, io, buffer, length, offset = 0)
287287

288288
if again?(result)
289289
if length > 0
290-
self.io_wait(fiber, io, IO::READABLE)
290+
self.io_wait(fiber, io, IO::WRITABLE)
291291
else
292292
return result
293293
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Fix several implementation bugs that could cause deadlocks on blocking writes.
6+
37
## v1.14.0
48

59
### Enhanced `IO::Event::PriorityHeap` with deletion and bulk insertion methods
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "io/event"
7+
require "io/event/selector"
8+
require "socket"
9+
10+
WriteDeadlock = Sus::Shared("write deadlock") do
11+
with "a pipe that fills up" do
12+
it "should not deadlock when waiting for writable" do
13+
# Skip on Windows which doesn't have the same socket behavior
14+
skip_if_ruby_platform(/mswin|mingw|cygwin/)
15+
16+
# Use UNIXSocket pair for more predictable behavior
17+
local, remote = UNIXSocket.pair(:STREAM)
18+
19+
# Set small buffer to encourage EAGAIN
20+
local.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
21+
remote.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
22+
23+
eagain_hit = false
24+
write_completed = false
25+
26+
# Fill buffer until we actually hit EAGAIN
27+
begin
28+
chunk = "X" * 1024 # 1KB chunks
29+
100.times { local.write_nonblock(chunk) } # Write up to 100KB
30+
rescue IO::WaitWritable
31+
eagain_hit = true
32+
end
33+
34+
# Skip test if we can't create EAGAIN condition
35+
skip "Could not trigger EAGAIN condition" unless eagain_hit
36+
37+
# Writer fiber that should hit EAGAIN and wait for WRITABLE
38+
writer = Fiber.new do
39+
buffer = IO::Buffer.for("test" * 64) # 256 bytes
40+
@selector.io_write(Fiber.current, local, buffer, buffer.size)
41+
write_completed = true
42+
end
43+
44+
# Start writer - should yield back when hitting EAGAIN
45+
writer.transfer
46+
47+
# Writer should be stuck waiting (either for right or wrong event)
48+
expect(writer.alive?).to be == true
49+
expect(write_completed).to be == false
50+
51+
# Drain some data to make socket writable
52+
remote.read_nonblock(2048)
53+
54+
# Give selector multiple chances to process writable event.
55+
# With fix: writer should wake up and complete.
56+
# With bug: writer stays stuck because it's waiting for READABLE.
57+
timeout_count = 0
58+
while writer.alive? && timeout_count < 10
59+
@selector.select(1.0) # Short intervals for responsiveness, many iterations for tolerance
60+
timeout_count += 1
61+
end
62+
63+
expect(write_completed).to be == true
64+
expect(writer).not.to be(:alive?)
65+
ensure
66+
local.close rescue nil
67+
remote.close rescue nil
68+
end
69+
end
70+
end
71+
72+
# Test all available selectors
73+
IO::Event::Selector.constants.each do |name|
74+
klass = IO::Event::Selector.const_get(name)
75+
76+
describe(klass, unique: name) do
77+
def before
78+
@loop = Fiber.current
79+
@selector = subject.new(@loop)
80+
end
81+
82+
def after(error = nil)
83+
@selector&.close
84+
end
85+
86+
attr :loop
87+
attr :selector
88+
89+
it_behaves_like WriteDeadlock
90+
end
91+
end

0 commit comments

Comments
 (0)