Summary
rb_fiber_scheduler_io_read_memory wraps borrowed memory with rb_io_buffer_new_locked, which starts at a lock count of one, passes the buffer to a user defined Fiber::Scheduler#io_read, and releases it with rb_io_buffer_free_locked. That function calls rb_bug unless the lock count is exactly one, and the scheduler can raise the count with #locked.
class TinySched
def io_read(io, buffer, length, offset)
Fiber.new { buffer.locked { Fiber.yield } }.resume # lock count 1 -> 2
0
end
def io_wait(io, events, timeout) = events
def kernel_sleep(duration = nil) = nil
def block(blocker, timeout = nil) = nil
def unblock(blocker, fiber) = nil
def close = nil
def fiber(&block) = Fiber.new(blocking: false, &block).tap(&:resume)
end
Fiber.set_scheduler(TinySched.new)
Fiber.schedule { r, w = IO.pipe; w.close; r.read(16) }
[BUG] rb_io_buffer_free_locked: expected lock count 1, got 2
ruby 4.1.0dev (2026-08-22T20:13:33Z master 37325e9f7a) +PRISM [arm64-darwin25]
No C extension is involved. rb_bug terminates the process immediately and cannot be rescued, and it is documented for interpreter bugs: doc/extension.rdoc says it "should be called under the situation caused by the bug in the interpreter", and doc/extension.ja.rdoc says it is for situations that can only arise from such a bug. This state is reachable by calling a public method.
rb_fiber_scheduler_io_write_memory, io_pread_memory and io_pwrite_memory have the same shape.
Why it matters here
SPEC.md section 4 lets the bridge multiplex requests onto fibers through a Fiber::Scheduler, and lets the application supply its own. A scheduler that leaves a lock on a buffer it was handed takes the whole process down, which section 1 forbids: an exception in application code must never crash the host.
It also makes the documented new_locked and free_locked pair unusable for any wrapper the bridge exposes to application code, since the application can raise the lock count the same way.
Proposal
Stop calling rb_bug for a state that ordinary Ruby code can produce. Preventing the state, for example by making the wrapper unlockable from Ruby, and handling the outstanding locks inside free_locked both seem possible. Which one fits depends on #44.
Summary
rb_fiber_scheduler_io_read_memorywraps borrowed memory withrb_io_buffer_new_locked, which starts at a lock count of one, passes the buffer to a user definedFiber::Scheduler#io_read, and releases it withrb_io_buffer_free_locked. That function callsrb_bugunless the lock count is exactly one, and the scheduler can raise the count with#locked.No C extension is involved.
rb_bugterminates the process immediately and cannot be rescued, and it is documented for interpreter bugs:doc/extension.rdocsays it "should be called under the situation caused by the bug in the interpreter", anddoc/extension.ja.rdocsays it is for situations that can only arise from such a bug. This state is reachable by calling a public method.rb_fiber_scheduler_io_write_memory,io_pread_memoryandio_pwrite_memoryhave the same shape.Why it matters here
SPEC.md section 4 lets the bridge multiplex requests onto fibers through a
Fiber::Scheduler, and lets the application supply its own. A scheduler that leaves a lock on a buffer it was handed takes the whole process down, which section 1 forbids: an exception in application code must never crash the host.It also makes the documented
new_lockedandfree_lockedpair unusable for any wrapper the bridge exposes to application code, since the application can raise the lock count the same way.Proposal
Stop calling
rb_bugfor a state that ordinary Ruby code can produce. Preventing the state, for example by making the wrapper unlockable from Ruby, and handling the outstanding locks insidefree_lockedboth seem possible. Which one fits depends on #44.