Skip to content

WorkerPool loses wakeup when the waiting fiber re-enters the pool - #229

Draft
ekmartin wants to merge 1 commit into
socketry:mainfrom
ekmartin:worker-pool-reentrant-lost-wakeup
Draft

ekmartin wants to merge 1 commit into
socketry:mainfrom
ekmartin:worker-pool-reentrant-lost-wakeup

Conversation

@ekmartin

@ekmartin ekmartin commented Oct 9, 2026

Copy link
Copy Markdown

This adds a failing test (no fix) for a lost wakeup in IO::Event::WorkerPool. It happens when the waiting fiber re-enters WorkerPool#call from inside the scheduler's block hook. That can happen in practice because Ruby runs pending GC finalizers inside block.

Sequence

WorkerPool#call enqueues the work and waits in worker_pool_work_wait. That calls rb_fiber_scheduler_block. When the fiber wakes and work->completed is still false, the wake is treated as spurious: the operation is cancelled and the fiber blocks again. The worker sets completed and calls rb_fiber_scheduler_unblock exactly once per work item.

rb_fiber_scheduler_block calls the scheduler's block method, which is Ruby code. Ruby can run finalizers inside it before the fiber actually suspends. On Ruby 4.0, IO#close and File.unlink (both used by Tempfile's finalizer) go through blocking_operation_wait. So a finalizer can call WorkerPool#call on the same fiber while that fiber's outer wait is in progress. With one worker:

  1. Work A is enqueued. The fiber enters the scheduler's block for A.
  2. A finalizer runs inside block and calls WorkerPool#call for nested work B. B queues behind A.
  3. The worker completes A and unblocks the fiber. The fiber is waiting for B at this point, so A's wakeup resumes B's wait. B isn't complete, so B is cancelled and the fiber blocks again. B is then interrupted, or skipped if the worker hasn't picked it up yet. Either way it's marked completed and unblocked, and the nested call returns.
  4. Control returns to the outer 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_ensure returns 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_nogvl returns a zero result, which looks like success to callers such as unlink. Side effects like a Tempfile unlink are lost without any error.

Test

ReentrantBlockScheduler (a TestScheduler subclass) stands in for the finalizer. Its block runs one nested IO::Event::WorkerPool.busy exactly once, before calling super. 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:

  • the outer busy(duration: 0.1) completes without being interrupted, and
  • the nested busy actually ran to completion.

The busy helper used to report a cancelled-before-start operation as result: :completed, because rb_nogvl returns 0. This PR adds an executed key 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):

🤔 Failed assertions:
describe IO::Event::WorkerPool with scheduler that re-enters the worker pool from block it wakes the outer operation and runs the nested operation test/io/event/worker_pool.rb:347
	expect {duration: 0.01, elapsed: 0.000625, executed: true, result: :cancelled, cancelled: true} to
		have {key :executed be == true, key :result be == :completed}
			key :executed be == true
				✓ has key test/io/event/worker_pool.rb:379
				expect true to
					be == true
						✓ assertion passed test/io/event/worker_pool.rb:379
			key :result be == :completed
				✓ has key test/io/event/worker_pool.rb:379
				expect :cancelled to
					be == :completed
						✗ assertion failed test/io/event/worker_pool.rb:379
	expect #<Sus::File[test/io/event/worker_pool.rb]::WaitTimeout: Sus::File[test/io/event/worker_pool.rb]::Wai… to
		be nil?
			✗ assertion failed test/io/event/worker_pool.rb:385
	expect {duration: 0.1, elapsed: 0.015274, executed: true, result: :exception, cancelled: true, exception: #… to
		have {key :executed be == true, key :result be == :completed}
			key :executed be == true
				✓ has key test/io/event/worker_pool.rb:386
				expect true to
					be == true
						✓ assertion passed test/io/event/worker_pool.rb:386
			key :result be == :completed
				✓ has key test/io/event/worker_pool.rb:386
				expect :exception to
					be == :completed
						✗ assertion failed test/io/event/worker_pool.rb:386

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: two unblocks 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.
  • The scheduler's block (e.g. Async::Scheduler#block): tolerate being re-entered before it suspends.
  • Ruby: avoid running finalizers inside fiber scheduler hooks, or defer them until the fiber has actually suspended.

🤖 Generated with Claude Code

…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>
@ekmartin

ekmartin commented Oct 9, 2026

Copy link
Copy Markdown
Author

I ran into this when trying out @Watson1978's new async functionality in rmagick! rmagick/rmagick@87ca690

This branch has not been deployed

No deployments
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.

1 participant