Skip to content

Phase 3: Response streaming for callable Rack bodies - #16

Open
Piyush-Goenka wants to merge 14 commits into
rage-rb:http-streamingfrom
Piyush-Goenka:Phase-3
Open

Phase 3: Response streaming for callable Rack bodies#16
Piyush-Goenka wants to merge 14 commits into
rage-rb:http-streamingfrom
Piyush-Goenka:Phase-3

Conversation

@Piyush-Goenka

Copy link
Copy Markdown

Summary

Adds the call(stream) detection branch, the seam that connects a Rack streaming body to the RackStream writer. With this, HTTP response streaming works end to end: a body that responds to call receives a stream writer and its chunks reach the client incrementally instead of being buffered.

What's included

  • call(stream) detection in ruby2c_response_send: after the existing String and each checks, a body responding to call takes the streaming path. Everything else is unchanged, so String, each, and the rack.upgrade / WebSocket / SSE flows are untouched.
  • RackStream creation and invocation: the branch creates the writer via IodineRackStream.create and invokes body.call(stream).
  • Producer runs inside a managed fiber (rb_fiber_new / rb_fiber_resume) rather than synchronously. This is required, not cosmetic: a streaming producer must be able to pause on backpressure, and calling Fiber.yield from the root fiber raises FiberError: attempt to yield on a not resumed fiber. Running the producer in its own fiber makes pausing possible.
  • Idempotent auto-close so the response is finalized even if the app never calls stream.close.
  • No new handle type: the branch sets IODINE_HTTP_NONE because the response is fully handled inline, which prevents a second http_finish from iodine_perform_handle_action.

Testing

Functional tests against a real Iodine server rather than per-function unit tests, matching how chunked_encoding_spec.rb works. A fixture app streams five chunks with small gaps, and the specs assert on actual wire behavior:

  • Body reassembly: status 200 and the full streamed body arrives intact.
  • Chunked framing: response uses transfer-encoding: chunked with no Content-Length.
  • Incremental delivery: chunk arrival times are captured over a raw socket and must span well beyond a single read. This is the test that actually proves streaming, since a buffered implementation would deliver everything at once and fail here.

Results:

  • 3 new streaming examples pass.
  • Full integration suite: 92 examples, 0 failures.
  • Unit suite: 16 examples, 0 failures.

Follow-ups

  • Backpressure is not in this PR. Pausing on :would_block and resuming on drain lands in the next one, along with the http1_on_ready drain hook and disconnect safety. The response_streaming_backpressure.ru fixture is included here as groundwork but no spec drives it yet.

Files

  • ext/iodine/iodine_http.c (detection branch + fiber helper)
  • spec/integration/response_streaming_spec.rb
  • spec/support/apps/response_streaming.ru
  • spec/support/apps/response_streaming_backpressure.ru

@rsamoilov rsamoilov left a comment

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.

Hi @Piyush-Goenka , left several comments.

Additionally, let's also think about stripping the content-length if it exists in the response.

Comment thread ext/iodine/iodine_http.c Outdated
VALUE stream = RARRAY_AREF(pair, 1);
IodineCaller.call2(body, iodine_call_proc_id, 1, &stream);
// idempotent auto-close so the response finalizes even if the app didn't.
IodineCaller.call(stream, close_method_id);

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.

This will interrupt the stream.

Consider the following code on the Rage side:

@__body = proc do |conn|
  Fiber.schedule do 
    5.times do
      conn.write("")
      sleep 1
    end
  end
end

This stream is expected to run for 5 seconds, but the close call here will stop it after the first write.

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.

And even if this call is removed, the stream will be closed anyway without http_pause.

Comment thread ext/iodine/iodine_http.c Outdated
// (pause/resume wiring lands in a follow-up step.)
VALUE stream = IodineRackStream.create(handle->h, Qnil);
VALUE pair = rb_ary_new_from_args(2, body, stream);
VALUE fiber = rb_fiber_new(iodine_stream_run_producer, pair);

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.

Non-blocking fibers can only be created via Fiber.schedule {} - in C, it would look something like rb_block_call(rb_cFiber, rb_intern("schedule"), ...). However, we shouldn't be creating fibers here at all - fibers should be managed on the Rage side, which is what's described in the proposal:

Rage handles the high-level logic of iterating the enumerator inside the Fiber
Iodine provides the low-level stream object and signals backpressure

Comment on lines +10 to +31
def raw_stream_get
times = []
headers = +""
Socket.tcp('localhost', server_port, connect_timeout: 1) do |sock|
sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
buf = +""
loop do
begin
data = sock.read_nonblock(4096)
times << Time.now
buf << data
rescue IO::WaitReadable
break unless IO.select([sock], nil, nil, 2)
retry
rescue EOFError
break
end
end
headers = buf.split("\r\n\r\n", 2).first.to_s
end
[headers, times]
end

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.

Let's stick to using the HTTP gem.

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.

Alright.
Will use the same in the future tests as well

Comment on lines +45 to +49
it 'delivers chunks incrementally rather than buffering the whole response' do
_, times = raw_stream_get
expect(times.length).to be > 1
# 5 chunks written 0.05s apart -> arrivals must span well beyond a single read
expect(times.last - times.first).to be > 0.1

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.

This test doesn't prove the first chunk reached the client before the producer finished. Instead, you can parse the chunked body and record when each expected chunk first appears.

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.

I have now used the time based approach and recorded when each chunk first appeared.

@Piyush-Goenka

Copy link
Copy Markdown
Author

Looking into it

@Piyush-Goenka
Piyush-Goenka marked this pull request as draft August 3, 2026 20:31
@Piyush-Goenka

Copy link
Copy Markdown
Author

Currently I am working on removing the iodine created producer fiber and also removing the producer Fiber storage from the RackStream.

@Piyush-Goenka

Copy link
Copy Markdown
Author

@rsamoilov
Pushed ecc74b0
Response now stays alive after body.call(stream) returns.

  • no auto close in C
  • proper http_pause lifecycle
  • raw http_s* is not kept after pausing

Honestly, i had missed this part in my proposal.
I had thought the response stays open after the handler returns, and that I could save the http_s * and write through it later. Both were wrong once I looked at how the parser actually works: http1_on_request finishes the response as soon as the handler returns, and the handle gets reset between requests (the thing you pointed out in the PR #15 review).

The write API and its return values are still exactly as proposed, only the ownership underneath changed: the stream now holds a one-use pause token, and only briefly gets a valid handle while writing or closing.

What this commit does overall:
The stream normally holds only a pause token, never the raw handle. When it needs to write or close, it briefly exchanges the token for a valid handle, does the work, and immediately pauses again to get a new token.

body.call returns -> pause the response, keep the one-use token

stream.write(chunk) -> try-resume with the token -> send -> pause again -> :ok (lock busy -> keep the token, return :would_block, nothing sent)

stream.close -> resume with the token -> http_finish -> free the context

Does this align with what you have thought over this how should this be designed ?

@Piyush-Goenka

Copy link
Copy Markdown
Author

At the start of this PR I had thought of making 2 PRs in the Phase 3 but know I am merging them and this would be the PR for the Phase 3. I am left with 3 things for this PR :

  1. Content-Length Framing
  2. Wake Bridge (wake blocked stream producers)
  3. Streaming tests.

@Piyush-Goenka

Copy link
Copy Markdown
Author

@rsamoilov
Once you confirm this ecc74b0 I will work on the Wake Bridge some work has already been done but yet to complete this and then moving towards the Rage.

@Piyush-Goenka
Piyush-Goenka marked this pull request as ready for review August 5, 2026 17:42
@Piyush-Goenka
Piyush-Goenka marked this pull request as draft August 6, 2026 21:03
@Piyush-Goenka

Copy link
Copy Markdown
Author

dff7a46
If the app set its own Transfer-Encoding header, it could end up on a response that was never actually chunked which breaks the response. Now we drop that header when streaming starts, and the server adds the correct one itself on the first write. Moreover also stripped the content length.

@Piyush-Goenka
Piyush-Goenka marked this pull request as ready for review August 8, 2026 09:29
@Piyush-Goenka Piyush-Goenka changed the title Phase 3.1: Detection seam and fiber execution Phase 3: Response streaming for callable Rack bodies Aug 9, 2026
@Piyush-Goenka

Copy link
Copy Markdown
Author

@rsamoilov
I have completed the Phase 3.
Could you please give it a look ?

@rsamoilov rsamoilov left a comment

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.

Looks good overall.

Comment thread ext/iodine/http.c Outdated
Comment thread ext/iodine/http1.c Outdated
Comment thread ext/iodine/http1.c Outdated
Comment thread ext/iodine/http1.c Outdated
@rsamoilov

Copy link
Copy Markdown
Member

Oh, by the way, there's another issue I can see - when testing your changes, I don't anymore see info logs when launching the server (e.g. "INFO: Starting up Iodine"). I don't see what changes here could've caused this, but it also works fine on master.

Do you see the same issue when you compile and install the project locally?

@Piyush-Goenka

Copy link
Copy Markdown
Author

Oh, by the way, there's another issue I can see - when testing your changes, I don't anymore see info logs when launching the server (e.g. "INFO: Starting up Iodine"). I don't see what changes here could've caused this, but it also works fine on master.

Do you see the same issue when you compile and install the project locally?

Yes, this is the same behaviour for me as well.
Logging is perfect in the master branch

@Piyush-Goenka

Copy link
Copy Markdown
Author

I was going through it what could be the cause.
The reason for this behaviour is :
fio.h marks the FIO_LOG_LEVEL variable as "weak". So every compiled file ends up with its own copy of it, and almost all of those copies are 0. Only the copy in fio.c holds the right default (INFO). The linker then keeps just one copy, and it does not promise which one.

On master it kept the INFO copy, so logs worked.
This PR adds one new file, and that alone made the linker keep a 0 copy instead, so the server starts with logging turned off.

@Piyush-Goenka

Copy link
Copy Markdown
Author

@rsamoilov
I have made all the fixes.

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.

2 participants