Summary
IO::Buffer.for frees its temporary wrapper in an ensure clause. If the block leaves a lock on the buffer, free raises IO::Buffer::LockedError and the rb_str_unlocktmp that follows it never runs, so the string stays temporarily locked for good.
str = +"hello world"
esc = nil
IO::Buffer.for(str) do |b|
Fiber.new { b.locked { Fiber.yield } }.resume
esc = b
end
# => IO::Buffer::LockedError: Buffer is locked!
str << "x" # => RuntimeError: can't modify string; temporarily locked
str.upcase! # => RuntimeError: can't modify string; temporarily locked
str.frozen? # => false
esc.get_string # => "hello world"
frozen? is false, so there is no way to recover the string, from Ruby or from C.
io_buffer_for_yield_instance_ensure(VALUE _arguments)
{
if (arguments->instance != Qnil) {
rb_io_buffer_free(arguments->instance);
}
if (!RB_OBJ_FROZEN(arguments->string)) {
rb_str_unlocktmp(arguments->string);
}
io_buffer_for_callback_ensure has the same ordering.
The wrapper survives too, still holding the string as its source. io_buffer_validate_slice re-checks the recorded bounds against the string's current memory on every access, so a T_STRING source has a partial backstop here, but it is a heuristic and not the guarantee the lock is meant to provide.
Reproduced on ruby 4.1.0dev (2026-08-22T20:13:33Z master 37325e9f7a).
Why it matters here
This gem does not use IO::Buffer.for with a block, but it is the same pattern the bridge uses: wrap borrowed memory, hand it to code you do not control, and invalidate it when the lifetime ends. It is the clearest reproduction of the invalidation failure tracked in #44, in a path that lives entirely inside CRuby.
What is actually broken
Two things, stated separately because only the first is unambiguous:
rb_str_locktmp is not paired with an rb_str_unlocktmp on this path. The lock is taken unconditionally and released only on the success path.
- The resulting string is unrecoverable and undiagnosable. It is not frozen, nothing reports that it is locked, and no API clears the flag.
There is a reading under which the ordering is deliberate. free refusing a locked buffer is clearly intentional, and if the wrapper could not be invalidated then handing write access back to the string would let it be modified or reallocated under a live wrapper. Keeping the string locked is a defensible fail-safe. But nothing in the code says so: there is no comment, no test covers the state of the string after the block, and the ordering predates reference-counted locks (c5a2c145c7) by years, so this looks unvisited rather than decided.
Proposal
No fix is proposed here, because the right one depends on what free should do about outstanding locks (#44).
For the record, one observation that may narrow the options: a buffer created by IO::Buffer.for has neither RB_IO_BUFFER_INTERNAL nor RB_IO_BUFFER_MAPPED, so io_buffer_free only zeroes the struct. There is no allocation to release, and the LockedError is purely a policy guard. Invalidating the wrapper and then unlocking would therefore be cheap. It is not obviously safe, though: zeroing clears buffer->source, so the string loses the pin that rb_io_buffer_type_mark gives it, and a suspended operation still pointing at that memory would be left without anything keeping it alive. That is the same question as #44.
Whatever the resolution, the string should not be left in a state that cannot be observed or undone.
Related
See #46 for a second, independent defect in the same ensure clause: when rb_str_locktmp itself raises, the ensure releases a lock it never took.
Summary
IO::Buffer.forfrees its temporary wrapper in an ensure clause. If the block leaves a lock on the buffer,freeraisesIO::Buffer::LockedErrorand therb_str_unlocktmpthat follows it never runs, so the string stays temporarily locked for good.frozen?is false, so there is no way to recover the string, from Ruby or from C.io_buffer_for_callback_ensurehas the same ordering.The wrapper survives too, still holding the string as its source.
io_buffer_validate_slicere-checks the recorded bounds against the string's current memory on every access, so aT_STRINGsource has a partial backstop here, but it is a heuristic and not the guarantee the lock is meant to provide.Reproduced on ruby 4.1.0dev (2026-08-22T20:13:33Z master 37325e9f7a).
Why it matters here
This gem does not use
IO::Buffer.forwith a block, but it is the same pattern the bridge uses: wrap borrowed memory, hand it to code you do not control, and invalidate it when the lifetime ends. It is the clearest reproduction of the invalidation failure tracked in #44, in a path that lives entirely inside CRuby.What is actually broken
Two things, stated separately because only the first is unambiguous:
rb_str_locktmpis not paired with anrb_str_unlocktmpon this path. The lock is taken unconditionally and released only on the success path.There is a reading under which the ordering is deliberate.
freerefusing a locked buffer is clearly intentional, and if the wrapper could not be invalidated then handing write access back to the string would let it be modified or reallocated under a live wrapper. Keeping the string locked is a defensible fail-safe. But nothing in the code says so: there is no comment, no test covers the state of the string after the block, and the ordering predates reference-counted locks (c5a2c145c7) by years, so this looks unvisited rather than decided.Proposal
No fix is proposed here, because the right one depends on what
freeshould do about outstanding locks (#44).For the record, one observation that may narrow the options: a buffer created by
IO::Buffer.forhas neitherRB_IO_BUFFER_INTERNALnorRB_IO_BUFFER_MAPPED, soio_buffer_freeonly zeroes the struct. There is no allocation to release, and theLockedErroris purely a policy guard. Invalidating the wrapper and then unlocking would therefore be cheap. It is not obviously safe, though: zeroing clearsbuffer->source, so the string loses the pin thatrb_io_buffer_type_markgives it, and a suspended operation still pointing at that memory would be left without anything keeping it alive. That is the same question as #44.Whatever the resolution, the string should not be left in a state that cannot be observed or undone.
Related
See #46 for a second, independent defect in the same ensure clause: when
rb_str_locktmpitself raises, the ensure releases a lock it never took.