Project

General

Profile

Actions

Bug #22428

open

Fiber scheduler: rb_nogvl returns as if the function ran when an offloaded operation is cancelled before execution

Bug #22428: Fiber scheduler: rb_nogvl returns as if the function ran when an offloaded operation is cancelled before execution

Added by watson1978 (Shizuo Fujita) about 17 hours ago.

Status:
Open
Assignee:
-
Target version:
-
ruby -v:
ruby 4.1.0dev (2026-10-12T07:31:01Z master 3edde2d2a2) +PRISM [x86_64-linux]
[ruby-core:127079]

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.

  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 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

repro.rb (707 Bytes) repro.rb watson1978 (Shizuo Fujita), 10/12/2026 08:22 AM
slow_op.c (612 Bytes) slow_op.c watson1978 (Shizuo Fujita), 10/12/2026 08:22 AM

No data to display

Actions

Also available in: PDF Atom