Issue #22428 has been reported by watson1978 (Shizuo Fujita). ---------------------------------------- Bug #22428: Fiber scheduler: rb_nogvl returns as if the function ran when an offloaded operation is cancelled before execution https://bugs.ruby-lang.org/issues/22428 * Author: watson1978 (Shizuo Fujita) * Status: Open * ruby -v: ruby 4.1.0dev (2026-10-12T07:31:01Z master 3edde2d2a2) +PRISM [x86_64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ## 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. ```c #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`. ```ruby 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. ```sh 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. 1. `rb_fiber_scheduler_blocking_operation_cancel` changes the status from `QUEUED` to `CANCELLED` without running the function. 2. `rb_fiber_scheduler_blocking_operation_execute` sees that the status is no longer `QUEUED` and returns `-1`. The function never runs, and `state->result` remains `NULL`. 3. `rb_fiber_scheduler_blocking_operation_wait` returns the scheduler's return value for the `CANCELLED` operation. `rb_nogvl` then returns `state.result`, which is `NULL`, with `errno` set to `0`. 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](https://github.com/socketry/io-event/pull/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](https://github.com/rmagick/rmagick/pull/1930) by checking a flag that its own function sets. ---Files-------------------------------- repro.rb (707 Bytes) slow_op.c (612 Bytes) -- https://bugs.ruby-lang.org/