gvl: release GVL during streaming I/O, re-acquire for Ruby block yields#82
gvl: release GVL during streaming I/O, re-acquire for Ruby block yields#82ruyrocha wants to merge 2 commits into
Conversation
Previously, `chunks` used `Yield::Iter` over a `BodyReceiver` iterator. `BodyReceiver::next` called `maybe_block_on`, which held the GVL across the entire Tokio future — blocking all other Ruby threads for the duration of each network wait. Replace with a Rust-driven loop inside `nogvl_cancellable`: - GVL is released while polling the async byte stream (network I/O) - GVL is re-acquired via `with_gvl` + `block_in_place` only to yield each chunk to the Ruby block - Thread interruption via Thread.kill is handled at every iteration step through the existing cancellation flag - Streaming errors now propagate as Ruby exceptions instead of being silently swallowed by the `and_then(|r| r.ok())` in the old iterator Add `gvl::with_gvl` as the counterpart to `nogvl`/`nogvl_cancellable`, add `rt::runtime()` to expose the global Tokio handle, and remove `rt::maybe_block_on` which is no longer needed. The Ruby block Proc is pinned against GC for the duration of streaming via `rb_gc_register_address`. Fixes SearchApi#57
Replace the placeholder gvl_streaming_test.rb with a full regression and
correctness suite covering every behaviour changed or fixed in this PR.
New test groups:
Basic functionality
- Yield count, String type, binary encoding, non-empty chunks
- nil return value regardless of block return
- Empty body (204), single-chunk total bytes
- LocalJumpError without block
Block control flow (new)
- break stops iteration cleanly without raising
- next continues iteration, skipping only the current block body
- Exception class and message preserved through the Rust loop
- Iteration stops at the correct chunk when block raises
Body ownership (extended)
- bytes/text after chunks raises MemoryError (both directions)
- GC registration leak regression: forced GC after double-consume
must not corrupt the heap (validates the GcGuard fix)
Content integrity (extended)
- Streamed bytes match buffered bytes for same endpoint
- 256 KB large body total size
- 20-chunk stream all received
GVL correctness (new/improved)
- Background ticker thread accumulates > 10 ticks during a 3-second
drip; uses Array#push instead of bare integer to avoid data race
- Mutex starvation regression (SearchApi#57): a waiter thread must acquire a
Mutex before streaming completes — the pre-fix BodyReceiver held
the GVL continuously, starving all other threads
- Concurrent streams: same client, different clients (3 threads)
Thread interruption (improved)
- Thread.kill during network wait: sets `started` flag before killing
so the test cannot pass vacuously if get() itself fails
- Thread.kill during block execution (sleeping inside the block)
- Thread.raise: injected exception class must be preserved end-to-end
Streaming error propagation (new)
- Timeout mid-stream raises a typed Wreq error
- Error is not silently swallowed as EOF (pre-fix regression)
GC safety (extended)
- Forced full GC + GC.compact between every chunk
- Separate compaction test (skipped if GC.compact unavailable)
- Aggressive GC during 8 KB stream must not corrupt content
close() integration
- close after full stream
- close after partial stream (break mid-way)
Client variants
- Module method (Wreq.get)
- Client instance
- POST response
|
No GVL is held during the maybe_block_on call; it only releases the GVL. |
|
This PR is not related to the issue at https://github.com/SearchApi/wreq-ruby/issues/57. |
|
@0x676e67 It seems they're going to be two separate issues. I'll drop the |
Yes, but we're putting this issue on hold for now (it doesn't seem easy to implement; it would require embedding the Ruby interpreter and handling interpreter initialization). However, PRs are welcome. We’ll only revisit this when we seriously consider releasing v2. |
|
A quick hint: if you've ever worked with PyO3, you'll understand the details. GVL is analogous to GIL—we also need to avoid blocking Rust async threads with GVL during iteration. You can check this out: |
|
@0x676e67 I never used PyO3 before :) I think the main gotcha here is around the differences between GVL & GIL, and no equivalent APIs. Instead of trying to acquire the GVL from a Tokio thread, spawn a genuine Ruby thread via |
That's a solid idea. Magnus should provide APIs for this. We generally avoid using rb-sys unless absolutely necessary, as it contains numerous unsafe APIs and requires extreme caution. |
Previously,
chunksusedYield::Iterover aBodyReceiveriterator.BodyReceiver::nextcalledmaybe_block_on, which held the GVL across the entire Tokio future — blocking all other Ruby threads for the duration of each network wait.Replace with a Rust-driven loop inside
nogvl_cancellable:with_gvl+block_in_placeonly to yield each chunk to the Ruby blockand_then(|r| r.ok())in the old iteratorAdd
gvl::with_gvlas the counterpart tonogvl/nogvl_cancellable, addrt::runtime()to expose the global Tokio handle, and removert::maybe_block_onwhich is no longer needed.The Ruby block Proc is pinned against GC for the duration of streaming via
rb_gc_register_address.Fixes #57