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>
Author
|
I ran into this when trying out @Watson1978's new async functionality in rmagick! rmagick/rmagick@87ca690 |
This was referenced Oct 9, 2026
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.
This adds a failing test (no fix) for a lost wakeup in
IO::Event::WorkerPool. It happens when the waiting fiber re-entersWorkerPool#callfrom inside the scheduler'sblockhook. That can happen in practice because Ruby runs pending GC finalizers insideblock.Sequence
WorkerPool#callenqueues the work and waits inworker_pool_work_wait. That callsrb_fiber_scheduler_block. When the fiber wakes andwork->completedis still false, the wake is treated as spurious: the operation is cancelled and the fiber blocks again. The worker setscompletedand callsrb_fiber_scheduler_unblockexactly once per work item.rb_fiber_scheduler_blockcalls the scheduler'sblockmethod, which is Ruby code. Ruby can run finalizers inside it before the fiber actually suspends. On Ruby 4.0,IO#closeandFile.unlink(both used byTempfile's finalizer) go throughblocking_operation_wait. So a finalizer can callWorkerPool#callon the same fiber while that fiber's outer wait is in progress. With one worker:blockfor A.blockand callsWorkerPool#callfor nested work B. B queues behind A.block, which transfers away to wait for A. A's only wakeup was used up in step 3, so the fiber never wakes. A is already complete, so if a timeout later raises into the fiber,worker_pool_work_ensurereturns immediately and the operation looks like it was interrupted.There is also a second effect: the nested operation is cancelled even though nothing asked for it to be cancelled. If it was still queued, it is skipped entirely, and
rb_nogvlreturns a zero result, which looks like success to callers such asunlink. Side effects like aTempfileunlink are lost without any error.Test
ReentrantBlockScheduler(aTestSchedulersubclass) stands in for the finalizer. Itsblockruns one nestedIO::Event::WorkerPool.busyexactly once, before callingsuper. The test uses a single-worker pool so B always queues behind A, and doesn't depend on GC timing. A watchdog fiber interrupts the outer fiber after 2s, so the test fails instead of hanging. The test checks two things:busy(duration: 0.1)completes without being interrupted, andbusyactually ran to completion.The
busyhelper used to report a cancelled-before-start operation asresult: :completed, becauserb_nogvlreturns 0. This PR adds anexecutedkey to its result so the skipped case can be detected too.Current output (Ruby 4.0.7; the same result with KQueue and Select on macOS, and EPoll and Select on Linux):
Where this was seen
A Falcon app receiving multipart uploads (Rack 2.2 buffers them to
Tempfiles, which are later finalized by GC). The setup was async 2.46.0, io-event 1.22.1, Ruby 4.0.7 on Linux (EPoll), Falcon 0.57.0, with a single-worker pool (ASYNC_SCHEDULER_WORKER_POOL=true). Requests that offloaded work right after a large allocation (which triggers GC) hung until the request timeout. With the worker pool disabled, the same workload had no failures. Per-fiber tracing showed the sequence above: twounblocks for the same fiber at the same moment, with the second one resuming a nested wait for a different work item, and the outer wait never getting another wakeup.Where a fix might go
I'm not sure where this is best fixed, and I'd value your view:
WorkerPool: never cancel on a wake that didn't come with an exception (just block again), and make the wait robust to re-entrancy. For example, if a nested wait on the same fiber consumes the wakeup for an enclosing work item that has completed, re-issue it after the nested call returns. A rough local prototype along these lines makes this test pass, but I haven't shared it here because I'm not confident it's the right shape.block(e.g.Async::Scheduler#block): tolerate being re-entered before it suspends.🤖 Generated with Claude Code