Summary
io_buffer_for_yield_instance takes the string lock after it has created the instance. If rb_str_locktmp raises, because the string is already temporarily locked, the ensure clause still runs rb_str_unlocktmp, releasing a lock that belongs to another frame.
str = +"hello world"
IO::Buffer.for(str) do |outer|
begin
IO::Buffer.for(str) { |inner| }
rescue => e
p e.message # => "can't modify string; temporarily locked"
end
str << "x" # succeeds: the outer frame's lock is gone
end
# => RuntimeError: temporal unlocking already unlocked string
The outer frame loses its lock while its buffer is still alive over the string's memory, and its own ensure then fails with temporal unlocking already unlocked string. Writing through the string in that window can move the allocation out from under the outer buffer:
str = +"hello world"
IO::Buffer.for(str) do |outer|
begin; IO::Buffer.for(str) { |inner| }; rescue; end
str << ("A" * 100000)
p outer # => #<IO::Buffer 0x00000001225312a8+11 EXTERNAL SLICE INVALID>
end
io_buffer_validate_slice happens to catch this case, because the source is a T_STRING and the recorded bounds no longer fall inside it. That is a heuristic backstop, not the pairing guarantee the lock is supposed to provide.
Reproduced on ruby 4.1.0dev (2026-08-23T05:56:50Z master e61f5c3b3d).
The asymmetry
io_buffer_for_callback_ensure, immediately below it in the same file, already guards against this:
static VALUE
io_buffer_for_callback_call(VALUE _arguments)
{
...
if (!RB_OBJ_FROZEN(arguments->string)) {
rb_str_locktmp(arguments->string);
arguments->locked = 1;
}
static VALUE
io_buffer_for_callback_ensure(VALUE _arguments)
{
...
if (arguments->locked) {
rb_str_unlocktmp(arguments->string);
}
struct io_buffer_for_yield_instance_arguments has no equivalent member, so the block form of IO::Buffer.for is missing the guard. rb_io_buffer_type_string shares the same ensure, but its string is freshly allocated and can never be locked already, so only .for reaches this.
Why it matters here
Same ensure clause as #45, but a distinct defect with a distinct trigger: there the lock is never released, here it is released by someone who does not hold it. It is also the pattern the bridge needs for its own wrappers: record whether the lock was actually taken, rather than inferring it from the state the frame was set up with.
Proposal
Track whether rb_str_locktmp actually ran, the way io_buffer_for_callback_call does.
struct io_buffer_for_yield_instance_arguments {
VALUE klass;
VALUE string;
VALUE instance;
enum rb_io_buffer_flags flags;
+ int locked;
};
This is independent of #44 and #45 and can be fixed on its own.
Summary
io_buffer_for_yield_instancetakes the string lock after it has created the instance. Ifrb_str_locktmpraises, because the string is already temporarily locked, the ensure clause still runsrb_str_unlocktmp, releasing a lock that belongs to another frame.The outer frame loses its lock while its buffer is still alive over the string's memory, and its own ensure then fails with
temporal unlocking already unlocked string. Writing through the string in that window can move the allocation out from under the outer buffer:io_buffer_validate_slicehappens to catch this case, because the source is aT_STRINGand the recorded bounds no longer fall inside it. That is a heuristic backstop, not the pairing guarantee the lock is supposed to provide.Reproduced on ruby 4.1.0dev (2026-08-23T05:56:50Z master e61f5c3b3d).
The asymmetry
io_buffer_for_callback_ensure, immediately below it in the same file, already guards against this:struct io_buffer_for_yield_instance_argumentshas no equivalent member, so the block form ofIO::Buffer.foris missing the guard.rb_io_buffer_type_stringshares the same ensure, but its string is freshly allocated and can never be locked already, so only.forreaches this.Why it matters here
Same ensure clause as #45, but a distinct defect with a distinct trigger: there the lock is never released, here it is released by someone who does not hold it. It is also the pattern the bridge needs for its own wrappers: record whether the lock was actually taken, rather than inferring it from the state the frame was set up with.
Proposal
Track whether
rb_str_locktmpactually ran, the wayio_buffer_for_callback_calldoes.struct io_buffer_for_yield_instance_arguments { VALUE klass; VALUE string; VALUE instance; enum rb_io_buffer_flags flags; + int locked; };This is independent of #44 and #45 and can be fixed on its own.