Bug #22428
openFiber scheduler: rb_nogvl returns as if the function ran when an offloaded operation is cancelled before execution
Description
Description¶
When a fiber scheduler cancels an offloaded blocking operation before a worker starts it, rb_nogvl returns normally even though the function never ran. It returns the zero-initialized result, so the caller cannot distinguish the cancellation from a successful call.
For example, File.exist? uses IO_WITHOUT_GVL with RB_NOGVL_OFFLOAD_SAFE. If the operation is cancelled before it starts, the zeroed stat result causes File.exist? to return true for a path that does not exist.
This report concerns Ruby's handling of cancellation before execution. Ruby handles the cancellation the same way whichever scheduler cancels the operation. The io-event behavior that triggers it is described under "Related work" below.
Reproduction¶
1. Create the C extension¶
Save the following as slow_op.c. The extension sleeps without the GVL. SlowOp.run returns true only if the C function actually ran.
#include <ruby.h>
#include <ruby/thread.h>
#include <stdint.h>
#include <time.h>
static void *
slow(void *arg)
{
long ms = (long)(intptr_t)arg;
struct timespec ts = { ms / 1000, (ms % 1000) * 1000000L };
while (nanosleep(&ts, &ts) != 0)
{
}
return (void *)1;
}
static VALUE
slow_op_run(VALUE self, VALUE ms)
{
void *ran = rb_nogvl(slow, (void *)(intptr_t)NUM2LONG(ms), RUBY_UBF_IO, NULL, RB_NOGVL_OFFLOAD_SAFE);
return ran ? Qtrue : Qfalse;
}
void
Init_slow_op(void)
{
VALUE mod = rb_define_module("SlowOp");
rb_define_module_function(mod, "run", slow_op_run, 1);
}
2. Create the Ruby script¶
Save the following as repro.rb. It requires the async gem.
The script stops each task around the time its offloaded call finishes. Each task then makes another offloaded call from its ensure block: either SlowOp.run(1) or File.exist?, depending on FIRST.
require 'async'
require 'slow_op'
counts = Hash.new(0)
Fiber.set_scheduler(Async::Scheduler.new(worker_pool: IO::Event::WorkerPool.new(maximum_worker_count: 4)))
Async do |top|
300.times do
tasks = Array.new(4) do
task = top.async do
SlowOp.run(20)
ensure
counts[:ensure] += 1
if ENV['FIRST'] == 'exist'
counts[:exist_true_for_missing_path] += 1 if File.exist?('/nonexistent/path')
else
counts[:slow_op_did_not_run] += 1 unless SlowOp.run(1)
end
end
top.async do
sleep(0.0195 + (rand * 0.002))
task.stop
end
task
end
tasks.each(&:wait)
end
end
Fiber.set_scheduler(nil)
p counts
3. Build and run¶
Run these commands in the directory containing both files.
ruby -rmkmf -e "create_makefile('slow_op')" && make
FIRST=slow_op ruby -I . repro.rb
FIRST=exist ruby -I . repro.rb
Expected result¶
Both runs should print {ensure: 1200} with no failure counters. Every SlowOp.run(1) call in ensure should return true, and every File.exist?('/nonexistent/path') call should return false.
Actual results¶
An example run on Linux with the default selector (URing):
$ ruby -v
ruby 4.1.0dev (2026-10-12T07:31:01Z master 3edde2d2a2) +PRISM [x86_64-linux]
$ ruby -rmkmf -e "create_makefile('slow_op')" && make
$ FIRST=slow_op ruby -I . repro.rb
{ensure: 1200, slow_op_did_not_run: 95}
$ FIRST=exist ruby -I . repro.rb
{ensure: 1200, exist_true_for_missing_path: 157}
The table shows the number of failures out of 1,200 stopped tasks in each run. The last two columns correspond to the FIRST=slow_op and FIRST=exist runs, respectively. The counts vary from run to run.
| Platform | Ruby | async / io-event | Selector | SlowOp.run(1) did not run |
File.exist? true for a missing path |
|---|---|---|---|---|---|
| Linux x86_64 | 4.0.7 (229531a6cf) | 2.46.0 / 1.22.1 | URing | 99 | 162 |
| Linux x86_64 | 4.0.7 (229531a6cf) | 2.46.0 / 1.22.1 | EPoll | 155 | 139 |
| Linux x86_64 | 4.1.0dev (3edde2d2a2) | 2.46.0 / 1.22.1 | URing | 67 | 79 |
| Linux x86_64 | 4.1.0dev (3edde2d2a2) | 2.46.0 / 1.22.1 | EPoll | 103 | 88 |
| FreeBSD 15.0 x86_64 | 4.0.3 (85ddef263a) | 2.46.0 / 1.22.1 | KQueue | 195 | 194 |
| macOS 27.2 arm64 | 4.0.6 (03b6d3f889) | 2.46.0 / 1.22.0 | KQueue | 28 | 33 |
Cause¶
When an operation is cancelled before execution, Ruby takes the following path.
rb_fiber_scheduler_blocking_operation_cancelchanges the status fromQUEUEDtoCANCELLEDwithout running the function.rb_fiber_scheduler_blocking_operation_executesees that the status is no longerQUEUEDand returns-1. The function never runs, andstate->resultremainsNULL.rb_fiber_scheduler_blocking_operation_waitreturns the scheduler's return value for theCANCELLEDoperation.rb_nogvlthen returnsstate.result, which isNULL, witherrnoset to0.
There is a fallback for an operation that is still QUEUED: rb_fiber_scheduler_blocking_operation_wait returns Qundef, which causes rb_nogvl to run the function in place. That fallback does not apply once the status is CANCELLED.
An operation cancelled during execution also ends as CANCELLED. The status alone therefore does not tell the caller whether the function ran.
Related work¶
IO::Event::WorkerPool in io-event 1.22.1 cancels a queued operation when the waiting fiber wakes up for a reason other than completion. In the reproduction, such a wakeup happens right after an Async task is stopped.
The open socketry/io-event#230 changes io-event so that it does not cancel an operation on this kind of wakeup. Its description also notes that a cancelled operation can appear to succeed. That change addresses the io-event trigger; this report concerns how Ruby handles the cancellation itself.
RMagick works around this in rmagick/rmagick#1930 by checking a flag that its own function sets.
Files
No data to display