Skip to content

IO::Buffer.for with a block unlocks the string even when it never locked it #46

Description

@himura467

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    upstream/rubyAn upstream issue in CRuby

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions