Skip to content

Reproduce replay after interrupted buffered writes - #1053

Draft
gmalette wants to merge 2 commits into
Shopify:masterfrom
gmalette:test/interrupted-buffered-writes
Draft

gmalette wants to merge 2 commits into
Shopify:masterfrom
gmalette:test/interrupted-buffered-writes

Conversation

@gmalette

Copy link
Copy Markdown

Situation

A fiber scheduler can write buffered bytes to the peer and then be interrupted before returning their count. Ruby's write-buffer position has not advanced, so closing the IO can send those bytes again.

Execution

I add a test-only reproduction covering explicit flush and buffered single- and multi-argument writes. The existing scheduler fixture performs real socket I/O, then yields before reporting the first write's result. The test reads the initial message from the peer, interrupts the writer, and asserts that closing it does not transmit another copy.

This draft intentionally contains failing tests, without a fix or any Async/io-event dependency.

Local verification

Ruby 4.0.6 and Ruby 4.1.0dev at 55562be3de each report 3 tests, 18 assertions, and 3 expected failures, with no errors or skips. Each failure observes Hello World after the initial copy has already been read, instead of the expected empty remainder.

## Why

I cover cancellation after a real socket write but before the fiber scheduler reports the byte count. Closing the stream then flushes the same buffered bytes a second time.

The regression exercises explicit flush, single-argument write, and multi-argument write through Ruby's existing scheduler fixture. I observe bytes at the socket peer rather than private buffer fields, and use no third-party scheduler dependency.

## Verification

Local runs on Ruby 4.0.6 and Ruby 4.1.0dev at 55562be each report 3 tests, 18 assertions, and 3 expected failures, with no errors or skips. Each failure receives the buffered message again after cancellation. This commit intentionally contains only the regression tests, not the fix.

Assisted-By: devx/2cdbdf48-1a60-4b23-b35e-42bee58a4326
Comment thread test/fiber/test_io.rb

private

def assert_no_replayed_buffer

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is backported from Shopify/dalli#81

The goal is to show that if the fiber gets interrupted after being blocked in a write, the internal buffer's accounting doesn't account for the bytes written to the kernel's buffer, closing the Ruby socket will flush the same bytes again.

## Why

A scheduler can transfer bytes and then unwind without returning their count. Ruby cannot safely retry that pending output buffer: close may send bytes that already reached the peer a second time. I discard the uncertain buffer and propagate the original interruption instead.

I apply the guard to explicit buffer flushes and to the buffered prefix of vector writes, which also covers buffer-overflow writes. Successful results, ordinary errno returns, and the fallback for schedulers without a write hook retain their existing handling. This does not roll back bytes already written or promise delivery of the remaining buffered data.

## Verification

On Ruby 4.1.0dev, the rebuilt source passes the three deterministic real-socket regressions with 3 tests and 18 assertions, and the complete `test/fiber/test_io.rb` with 13 tests and 53 assertions. Both runs have no failures, errors, or skips. Runtime identity confirms that the executable and loaded shared library come from the patched build.

On Ruby 4.0.6, the same regressions pass, as do the native io_uring reproduction and its no-cancellation control. The IO/fiber selection passes 347 tests with one portability skip, the IO bootstrap selection passes 18 tests, and the IO specs pass 1517 examples.
Comment thread io.c
Comment on lines +1401 to +1402
fptr->wbuf.off = 0;
fptr->wbuf.len = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How do we know that the bytes have actually been written and the write occurred, without checking the return value (which we can't be cause there was an exception).

Dropping bytes could be as bad as replaying them

Comment thread io.c

if (!UNDEF_P(result)) {
return rb_fiber_scheduler_io_result_apply(result);
if (fptr->wbuf.len && iov[0].iov_base == fptr->wbuf.ptr + fptr->wbuf.off) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we use this branch only in some cases?

Comment thread io.c
Comment on lines 1487 to 1488
fptr->wbuf.off += result;
fptr->wbuf.len -= result;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It feels like this is the code that needs to run, but the challenge is that we're not getting result? (because of the interrupt)

@samuel-williams-shopify

Copy link
Copy Markdown

Interrupted writes should properly clear the write buffer. I'm working on sorting this out.

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.

3 participants