Repository navigation
Conversation
…block`. If the scheduler's `block` hook performs a nested `WorkerPool#call` on the same fiber before suspending (as a GC finalizer can, e.g. `Tempfile` unlinking its file), the outer operation's wakeup resumes the nested wait instead. The nested operation is cancelled and the outer wait never wakes again. Also record whether the `busy` test operation actually executed, since an operation cancelled before it starts is otherwise reported as completed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ruby can run GC finalizers inside the scheduler's `block` hook before the fiber suspends, and a finalizer that offloads a blocking operation (e.g. a `Tempfile` finalizer closing and unlinking its file) re-enters `WorkerPool#call` on the same fiber. Wakeups are addressed to the fiber, so the outer work's wakeup could resume the nested wait, which cancelled the nested operation and consumed the only wakeup for the outer one, which then never woke. - Only an exception or other non-local exit cancels the work. A plain wakeup no longer cancels it; the wait loops until the work's own wakeup has been accounted for. - Track the work each fiber is waiting for (innermost first, per pool). A wakeup is attributed to the innermost completed work which hasn't been woken yet, or ignored as spurious. When a nested call returns, any wakeup it consumed on behalf of outer work is re-issued with `rb_fiber_scheduler_unblock`. Add tests for the outer work completing before or during the nested call, the nested work finishing first, two levels of nesting with one or more workers, an exception raised while nested, and a spurious wakeup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
|
FYI, I reported the Ruby side of this to https://bugs.ruby-lang.org/issues/22428. |
A nested block can consume the outer operation's completion wakeup and then raise before wakeup accounting runs. If a finalizer swallows that exception, the outer operation remains blocked even after both operations have finished. Account for exceptional exits from the initial wait and from cancellation cleanup, while preserving the original exception. Since block does not report whether it consumed a wakeup, exceptional exits are accounted for conservatively and may replay an extra wakeup. Add regressions for interruptions during the normal wait and cancellation drain loop, using a queue to control worker progress. Both tests fail without the fix. Update the release note to cover interrupted nested waits. Validation on macOS with Ruby 4.0.6: - Full suite: 325 passed, 21 skipped. - Worker pool: 23 passed with both KQueue and Select. - RuboCop: no offenses in the changed Ruby test file.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the lost wakeup reproduced by #229, and includes that PR's test commit. The fix is in the second commit.
The problem
Wakeups from
WorkerPoolare addressed to the fiber, not to the work item. Ruby can run GC finalizers inside the scheduler'sblockhook before the fiber suspends. If a finalizer offloads a blocking operation (e.g.Tempfile's finalizer closing and unlinking its file), the same fiber re-entersWorkerPool#call. The outer work's wakeup then resumes the nested wait, which:rb_nogvlreturned as if it had run, so theunlinkwas silently lost.Why a plain wake used to cancel
The cancel-on-wake comes from the first version of the wait loop (0345ded). It wrapped
blockinrb_protectand cancelled whenever the fiber woke without the work being complete. The intended case was an exception (cancel, then wait for the worker to drain). A wake without an exception took the same path because the two weren't distinguished. 84e03fb kept that shape, and #209 split the loop into a wait part and anrb_ensurepart, still cancelling on plain wakes in the wait part.I couldn't find anything that relies on a plain wake meaning "cancel":
Task#stop/#cancelraiseAsync::Cancelinto the fiber (Fiber.scheduler.raise→Selector#raise→Fiber#raise), andwith_timeoutraises too.fiber_interruptraises.Thread#raise/#killbecome exceptions when the fiber is resumed, becausefiber_switchchecks pending interrupts.All of these unwind through
rb_ensure, which still cancels the work and drains it. A wake without an exception and without completion is a staleunblock(cf. socketry/async#467 and #469, where Async now retriesio_wait/process_waiton stale wakeups) or this re-entrancy. RMagick'srmagick_gvl.cppdocuments the consequence: "IO::Event::WorkerPool can also cancel a call that no worker has started without raising, when the fiber wakes up early right after a task is stopped, and Ruby then returns as if the call had run". RMagick re-runs such calls to work around it.So the distinction is now explicit: an exception or other non-local exit cancels and drains, and any other wake just keeps waiting.
The fix
worker_pool_work_waitloops until the work's own wakeup has been accounted for, and never cancels.st_table, protected by the GVL). Each work item records theouterwork the same fiber was already waiting for.rb_fiber_scheduler_unblockfor any outer work whose wakeup it consumed. The outerblockthen resumes once its work is complete, whether that happened before, during or after the nested call.The accounting keeps this invariant: pending wakeups for the fiber ≥ completed-but-not-woken work in its chain. So a wait never blocks when its work is done and no wakeup is coming. It doesn't depend on GC timing or the number of workers.
Alternatives considered
while (!completed) block) isn't enough. I tried it: the spurious-wakeup test passes, but 4 of the re-entrancy tests still time out, because the nested wait still consumes the outer wakeup.WorkerPoolinstances. But it either exposes a raw pointer to Ruby code or depends on the fiber not being frozen. A per-pool table is invisible to Ruby and Ractor-safe, since pools aren't shareable. Nesting across pools doesn't happen with a scheduler that uses one pool.Scheduler / Ruby level
The underlying issue is that Ruby runs finalizers (and other deferred work) inside fiber scheduler hooks, which re-enters the scheduler from arbitrary points. Deferring finalizers until the fiber has actually suspended, or masking them inside scheduler hooks, would be the more general fix, and it's out of scope here. One consequence that
WorkerPoolcan't fix: if an exception (e.g.Async::Cancelor a timeout) is raised into the fiber while it's in a nested wait inside a finalizer, the nested operation is correctly cancelled and drained. But Ruby discards exceptions from finalizers, so the outer wait doesn't see the exception and keeps waiting for its operation to complete.Testing
ReentrantBlockSchedulerfrom #229 now takes a list of nested durations, so it can nest more than once. It also takes an optional hook that runs before the nested call. New tests:completed_count)block, and both are drained beforecallreturns (call_count == completed_count == 2,current_queue_size == 0)unblockof the waiting fiber doesn't cancel the operationWithout the fix, 5 of these fail. The nested-finishes-first and exception-while-nested tests pass before and after, as regression guards.
worker_pool.rbpasses withIO_EVENT_SELECTOR=SelectandKQueue, and in 25 back-to-back runs.ruby:4.0, with liburing):worker_pool.rbpasses with EPoll, URing and Select. Full suite: 419 passed, 21 skipped.Benchmark
There's no worker pool benchmark in the repository, so I used an ad-hoc one: 100 fibers × 200
busy(duration: 0)calls throughTestScheduler, best of 5. There's no measurable difference:With Async and real
TempfilefinalizersSetup: async 2.46.0 with
ASYNC_SCHEDULER_WORKER_POOL=true(single worker). The test creates 20 garbageTempfiles, then a task insidewith_timeout(3)does 20File.writes. Ablockwrapper runsGC.start, so the finalizers'close/unlinkhappen insideblock:call_count == completed_count)I also tried a Rack 3.2 multipart workload under Async with a single worker and large allocations, without forcing GC inside
block. With natural GC timing, finalizers never landed inside aWorkerPoolblocklocally (0 re-entrant calls), so it didn't reproduce the hang on either build.🤖 Generated with Claude Code