Summary
IO::Buffer locks are reference counted, and a lock taken on a slice applies to its root (GH-18429). A locked buffer refuses both free and resize, so code holding a slice can stop the owner of the root from invalidating it.
root = IO::Buffer.new(16)
slice = root.slice(0, 4)
Fiber.new { slice.locked { Fiber.yield } }.resume
root.locked? # => true
root.free # => IO::Buffer::LockedError: Buffer is locked!
root.resize(32) # => IO::Buffer::LockedError: Cannot resize locked buffer!
slice.get_string # => "\x00\x00\x00\x00"
#lock is not exposed to Ruby, so a lock outlives its block only by suspending inside #locked, but a fiber or a thread is enough.
There is no documented way for the owner to end the lifetime anyway. free refuses, rb_io_buffer_free_locked calls rb_bug (#43), and draining with rb_io_buffer_try_unlock contradicts the rule stated in include/ruby/io/buffer.h that "Every successful lock call must be paired with exactly one unlock call".
Reproduced on ruby 4.1.0dev (2026-08-22T20:13:33Z master 37325e9f7a).
Why it matters here
SPEC.md section 2 requires the bridge to invalidate every handed-out buffer when call returns. Locking lets the application refuse that. The other prohibitions in section 2 are harmless when broken: free on a slice does not touch the borrowed memory, and resize produces a safe copy (see also #7). A lock is different, because the bridge cannot carry out the invalidation at all.
When the invalidation fails, the root keeps a base pointer into host memory and slices over it still pass validation. Once the host reclaims that memory after nsgi_handle returns, the application can read freed memory. The assumption in #6 that memory safety holds because freeing nulls the pointer only holds while free succeeds.
Proposal
Decide what happens when the owner of borrowed memory has to end its lifetime while another party holds a lock. Locks exist to keep memory alive across a system call, so having free ignore them in general would not be right, but a temporary wrapper around borrowed memory has no choice: the memory is gone once the owner returns.
Whatever the resolution, what this gem needs is a way for the owner to end the lifetime unconditionally, without aborting the process and without raising into the host.
Summary
IO::Bufferlocks are reference counted, and a lock taken on a slice applies to its root (GH-18429). A locked buffer refuses bothfreeandresize, so code holding a slice can stop the owner of the root from invalidating it.#lockis not exposed to Ruby, so a lock outlives its block only by suspending inside#locked, but a fiber or a thread is enough.There is no documented way for the owner to end the lifetime anyway.
freerefuses,rb_io_buffer_free_lockedcallsrb_bug(#43), and draining withrb_io_buffer_try_unlockcontradicts the rule stated ininclude/ruby/io/buffer.hthat "Every successful lock call must be paired with exactly one unlock call".Reproduced on ruby 4.1.0dev (2026-08-22T20:13:33Z master 37325e9f7a).
Why it matters here
SPEC.md section 2 requires the bridge to invalidate every handed-out buffer when
callreturns. Locking lets the application refuse that. The other prohibitions in section 2 are harmless when broken:freeon a slice does not touch the borrowed memory, andresizeproduces a safe copy (see also #7). A lock is different, because the bridge cannot carry out the invalidation at all.When the invalidation fails, the root keeps a base pointer into host memory and slices over it still pass validation. Once the host reclaims that memory after
nsgi_handlereturns, the application can read freed memory. The assumption in #6 that memory safety holds because freeing nulls the pointer only holds whilefreesucceeds.Proposal
Decide what happens when the owner of borrowed memory has to end its lifetime while another party holds a lock. Locks exist to keep memory alive across a system call, so having
freeignore them in general would not be right, but a temporary wrapper around borrowed memory has no choice: the memory is gone once the owner returns.Whatever the resolution, what this gem needs is a way for the owner to end the lifetime unconditionally, without aborting the process and without raising into the host.