Phase 3: Response streaming for callable Rack bodies - #16
Conversation
rsamoilov
left a comment
There was a problem hiding this comment.
Hi @Piyush-Goenka , left several comments.
Additionally, let's also think about stripping the content-length if it exists in the response.
| 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); |
There was a problem hiding this comment.
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
endThis stream is expected to run for 5 seconds, but the close call here will stop it after the first write.
There was a problem hiding this comment.
And even if this call is removed, the stream will be closed anyway without http_pause.
| // (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); |
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
Let's stick to using the HTTP gem.
There was a problem hiding this comment.
Alright.
Will use the same in the future tests as well
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I have now used the time based approach and recorded when each chunk first appeared.
|
Looking into it |
|
Currently I am working on removing the iodine created producer fiber and also removing the producer Fiber storage from the RackStream. |
|
@rsamoilov
Honestly, i had missed this part in my proposal. 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:
Does this align with what you have thought over this how should this be designed ? |
|
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 :
|
|
@rsamoilov |
|
dff7a46 |
|
@rsamoilov |
|
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. |
|
I was going through it what could be the cause. On master it kept the INFO copy, so logs worked. |
|
@rsamoilov |
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 tocallreceives a stream writer and its chunks reach the client incrementally instead of being buffered.What's included
call(stream)detection inruby2c_response_send: after the existingStringandeachchecks, a body responding tocalltakes the streaming path. Everything else is unchanged, soString,each, and therack.upgrade/ WebSocket / SSE flows are untouched.IodineRackStream.createand invokesbody.call(stream).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 callingFiber.yieldfrom the root fiber raisesFiberError: attempt to yield on a not resumed fiber. Running the producer in its own fiber makes pausing possible.stream.close.IODINE_HTTP_NONEbecause the response is fully handled inline, which prevents a secondhttp_finishfromiodine_perform_handle_action.Testing
Functional tests against a real Iodine server rather than per-function unit tests, matching how
chunked_encoding_spec.rbworks. A fixture app streams five chunks with small gaps, and the specs assert on actual wire behavior:transfer-encoding: chunkedwith noContent-Length.Results:
Follow-ups
:would_blockand resuming on drain lands in the next one, along with thehttp1_on_readydrain hook and disconnect safety. Theresponse_streaming_backpressure.rufixture 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.rbspec/support/apps/response_streaming.ruspec/support/apps/response_streaming_backpressure.ru