Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions lib/io/stream/readable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,19 @@ def read(size = nil, buffer = nil)
end

if size
until @finished or @read_buffer.bytesize >= size
# Compute the amount of data we need to read from the underlying stream:
read_size = size - @read_buffer.bytesize

# Don't read less than @minimum_read_size to avoid lots of small reads:
fill_read_buffer(read_size > @minimum_read_size ? read_size : @minimum_read_size)
if @finished or @read_buffer.bytesize >= size
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read(size) now calls flush when @finished is true. That means a buffered read after EOF can raise (e.g. EPIPE/IOError from flushing pending writes) and prevent returning data that’s already in @read_buffer, which is a behavior change from before. If the intent is only to fix the “buffer satisfied size so fill_read_buffer is skipped” case, consider only flushing when @read_buffer.bytesize >= size (or otherwise ensure EOF-buffered reads can still succeed without being interrupted by write-side errors).

Suggested change
if @finished or @read_buffer.bytesize >= size
if @read_buffer.bytesize >= size

Copilot uses AI. Check for mistakes.
# We have enough data in the read buffer, but we should still flush pending writes:
self.flush
else
while true
# Compute the amount of data we need to read from the underlying stream:
read_size = size - @read_buffer.bytesize

# Don't read less than @minimum_read_size to avoid lots of small reads:
fill_read_buffer(read_size > @minimum_read_size ? read_size : @minimum_read_size)

break if @finished or @read_buffer.bytesize >= size
end
Comment on lines +104 to +112
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This addresses the flush-skipped path for #read, but other APIs (e.g. read_partial, peek, gets/read_until) can also return purely from @read_buffer without calling fill_read_buffer (which is where flushing currently happens). That can leave the same “pending writes never flushed” hazard for those methods. Consider centralizing “flush before serving data from the read buffer” in a shared helper (or applying the same flush-on-buffer-hit behavior to the other read entry points) to keep semantics consistent.

Copilot uses AI. Check for mistakes.
end
else
until @finished
Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Remove old OpenSSL method shims.
- Ensure `IO::Stream::Readable#read` calls `#flush` even if buffered data is sufficient to satisfy the read, to maintain consistent behavior.

## v0.11.0

Expand Down
25 changes: 25 additions & 0 deletions test/io/stream/buffered.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,8 @@ def after(error = nil)
end

describe "Socket.pair" do
include Sus::Fixtures::Async::ReactorContext

let(:sockets) {Socket.pair(:UNIX, :STREAM)}
let(:client) {IO::Stream::Buffered.wrap(sockets[0])}
let(:server) {IO::Stream::Buffered.wrap(sockets[1])}
Expand All @@ -1022,6 +1024,29 @@ def after(error = nil)

it_behaves_like AUnidirectionalStream
it_behaves_like ABidirectionalStream

it "flushes pending writes even when read buffer already has data" do
# When read-ahead pulls more data than requested, a subsequent read can
# complete from the buffer without calling fill_read_buffer — skipping
# flush. This test verifies that pending writes are still flushed.
task_a = reactor.async do
client.write("A1")
data = client.read_exactly(2)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data = client.read_exactly(2) assigns to data but the value is never used. Consider removing the assignment (or asserting on the returned value) to keep the test focused and avoid unused locals.

Suggested change
data = client.read_exactly(2)
client.read_exactly(2)

Copilot uses AI. Check for mistakes.
client.write("A2")
client.read_exactly(2)
end

task_b = reactor.async do
server.write("B1")
server.read_exactly(2)
server.write("B2")
server.read_exactly(2)
end

# Without the fix, this deadlocks because A2 is never flushed.
task_a.wait
task_b.wait
Comment on lines +1047 to +1048
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written, a regression will manifest as a deadlock and this example will hang indefinitely (waiting on task_a/task_b) rather than failing fast. Please wrap the waits in a bounded timeout (using the Async/Sus reactor context utilities) so CI gets a deterministic failure instead of a stuck test run.

Suggested change
task_a.wait
task_b.wait
reactor.with_timeout(1) do
task_a.wait
task_b.wait
end

Copilot uses AI. Check for mistakes.
end
end

describe TCPSocket do
Expand Down
Loading