Project

General

Profile

Actions

Feature #22274

open

Make `IO::Buffer` no longer experimental.

Feature #22274: Make `IO::Buffer` no longer experimental.
1

Added by ioquatix (Samuel Williams) 24 days ago. Updated about 7 hours ago.

Status:
Open
Target version:
-
[ruby-core:126531]

Description

IO::Buffer was introduced in Ruby 3.1 by Feature #18020 as an experimental API. It provides an efficient buffer abstraction for fiber scheduler I/O, zero-copy operations, binary protocol implementations, and access from native extensions.

Since then, the API has seen several Ruby releases of real-world use and substantial development. Ruby 4.1 now has cohesive semantics for buffer ownership and lifecycle, slicing, locking, string and file mappings, MemoryView integration, and single-transfer I/O operations.

I propose making both the Ruby and public C interfaces of IO::Buffer non-experimental in Ruby 4.1.

This would involve:

  • Removing the allocation-time experimental warning.
  • Removing the experimental status from the class documentation.
  • Removing RB_IO_BUFFER_EXPERIMENTAL from the public C header.
  • Removing warning suppression that is only required because IO::Buffer is experimental.
  • Updating NEWS to describe IO::Buffer as stable.

RUBY_IO_BUFFER_VERSION would remain available for compile-time feature detection as the interface continues to evolve.

Before stabilizing the interface, I would particularly appreciate feedback from JRuby and TruffleRuby maintainers.

Related work:

Updated by Eregon (Benoit Daloze) 11 days ago · Edited Actions #1 [ruby-core:126624]

Thank you for creating this issue to discuss it.

I have several points in no particular order.

  • There is a lot of work on IO::Buffer recently, this is great. But it might also mean some new bugs, maybe we should wait some time so it gets "battle tested" (= in a release 4.1.0, and some time for it to be tested in production for various apps) before declaring it stable?
  • https://github.com/ruby/ruby/pull/18483 is a large breaking change, it swaps the order of arguments from length, offset to offset, length. That I think could break many existing usages of IO::Buffer in subtle ways. Which makes me think maybe there is a possibility this needs to be reverted, in which case having it marked stable looks a bit strange. I do hope it doesn't need to be reverted though, or at least if it is that it happens before the 4.1.0 release. I think we have never seen a core API changing positional arguments order before (or at least not before several deprecation/migration phases in between), but since IO::Buffer has been experimental so far maybe it's OK?
  • From my implementation of IO::Buffer in TruffleRuby I recall two things: #slice returns a "sliced IO::Buffer" which is pretty tricky to implement as e.g. just copying the char* in the slice is not OK, as the parent buffer might be resized, free'd, etc. So I think every slice needs to always go through the parent buffer, and can't assume the parent buffer didn't change. Also slices are writable, which limits a lot of how it can be implemented. Currently CRuby does not go through the parent but captures the raw pointer and validates it, which affects semantics, see below.
  • Error messages seems inconsistent with other core API, e.g. ArgumentError: Size can't be negative! vs ArgumentError: negative array size. I think consistency is valuable there (otherwise I think it won't "feel" like a core API).

I asked Claude to compare the TruffleRuby and CRuby master implementations and find potential issues or behavior that could be clarified/improved:


1. set_string is not lock-protected and can segfault under concurrent resize

IO::Buffer#set_string with a payload ≥ IO_BUFFER_BLOCKING_SIZE (1 MiB) segfaults on master when another thread resizes the same buffer concurrently:

Warning[:experimental] = false
big = 4 * 1024 * 1024
60.times do
  b = IO::Buffer.new(big)
  src = "Z" * (big / 2)
  t = Thread.new { sleep(rand * 0.0008); b.resize(64 * 1024) rescue nil }
  b.set_string(src)   # [BUG] Segmentation fault in io_buffer_memmove_blocking
  t.join
end
-- C level backtrace ------------------
__memcpy_avx_unaligned_erms
io_buffer_memmove_blocking      io_buffer.c:3011
rb_nogvl                        thread.c:1820
io_buffer_copy_from             io_buffer.c:3063
io_buffer_set_string            io_buffer.c:3312

The cause is an asymmetry with copy. Both go through io_buffer_copy_fromio_buffer_memmove, which releases the GVL for copies ≥ 1 MiB. copy was hardened to lock both buffers (rb_io_buffer_locked_for_reading / _for_writing, commit 753ace46fe), so a concurrent resize hits a locked buffer and cannot realloc. set_string (io_buffer_set_stringio_buffer_copy_from) takes no such lock, so the resize reallocates the base out from under the in-flight memmove → use-after-free.

Running each write path against the same race in isolation:

method result why
set_string SIGSEGV releases GVL, does not lock destination
copy ok locks both sides
clear ok keeps GVL (plain memset)
read ok locks via io_buffer_blocking_region

The fix is presumably to give set_string the same locked scope copy already uses.

2. Slice validity is address-based — two asymmetric edge cases worth ratifying

A slice captures an absolute pointer (source->base + offset) at creation and re-validates on every access by checking the pointer still falls within the source's current range (io_buffer_validate_slice). This correctly catches a freed/transferred source (base → NULLInvalidatedError) and a source shrunk past the slice. But because validation is address-containment rather than offset-based, two cases are asymmetric, and I'd suggest confirming they're intended before the interface is frozen:

  1. A resize that moves the allocation (realloc/mremap relocating) invalidates the slice even when offset + length would still logically fit. Reproduced for both realloc (grow to 1 MiB) and mremap (mapped buffer grown ~8 MiB): slice valid? becomes false every time.

  2. If the source is later reallocated back over the slice's old address (ABA), the slice silently becomes valid again but now points at semantically-unrelated bytes. Reproduced: free a 64-byte buffer, resize(64) back → the stale slice resurrected and read the new contents in 8/10 runs. Memory-safe (still inside a live allocation), but returns wrong data.

Case 2 is currently documented as intended (cc706f3956, 4d5ae42629). It's sharp enough to be worth an explicit decision, and relevant to this ticket's request for feedback from other implementations: an offset-based slice design (the natural fit for TruffleRuby and JRuby, where the backing store can move under a managed GC) cannot reproduce the address-reuse revalidation semantics at all, and would keep case 1's slices valid rather than invalidating them. If the spec is defined in terms of observable valid? results across resize, we'd want it worded so an offset-based implementation can conform.

3. Minor points

  • freeze only guards lifecycle, not contents. A frozen IO::Buffer still accepts set_value / set_string (only free / resize / transfer raise FrozenError). This is surprising for a frozen object and worth either documenting explicitly or reconsidering before stable.

  • initialize is re-callable and leaks. io_buffer_initialize overwrites base/size/flags without releasing the previous allocation. Re-invoking it on a mapped buffer silently leaks the mapping (mapped? flips to false, no munmap); on an internal buffer it leaks the old malloc. Doing it inside #locked also zeroes lock_count, so the ensuing unlock underflows and raises the misleading LockedError: "Buffer not locked!". It'd be safer for initialize to release the old buffer first, or to refuse re-initialization.

Happy to file (1) as a separate bug if that's easier to track.


I think semantically it would be best to use "parent buffer + offset" for slices on CRuby too (cleaner semantics, easier to understand and document, also harder to misuse).
Absolute pointers are not available on JRuby at all, and on TruffleRuby for Ruby Strings living in the managed heap (byte[]).

Updated by ioquatix (Samuel Williams) 11 days ago Actions #2 [ruby-core:126626]

Thanks - a few thoughts -

For stability, I'm not thinking of bug free, but just "won't change [much] in the future". Also, I'd be okay with defining a subset of the Ruby and C interfaces as stable and leaving others as experimental if that's an easier bar to get over.

Regarding the order of arguments change, that's only the surface - we also changed the semantics of the scheduler hooks and most major schedulers have now adopted that change so I'm not planning on reverting it. Yes, it was all marked as experimental - and yes it's painful, but it's the right way forward as the previous design was bad on multiple levels. That's all on me. I'd rather fix it now as we did than have to live with it forever.

Regarding set_string - some operations are missing locking since it was only recently introduced, but that will hopefully fix the set_string issue. Also, I don't think we expect IO::Buffer mutations to be thread safe so the only goal is to not crash but we don't guarantee anything about the behaviour if used on different threads without coordination (as I'm sure you'd agree with). It might be nice if we can share frozen IO::Buffer instances between ractors though - or perhaps establish some semantics like this.

Re slices, I don't have a strong opinion about how they work. I think the expectation is that IO::Buffer instances have stable addresses, because this is a primitive for use with system calls and operating system interfaces like io_uring registered buffers. So having a GC move the internal backing memory around is a non-starter IMHO. Slicing is based on that design - stable addressing - slices usually won't outlive their backing store being resized. It would probably be more work to invalidate all slices than to allow them to remain valid (if they still land in the source allocation).

So, to be more specific, if you can't model slices as absolute addresses + validation, an offset is probably okay. I think that if a slice became invalid after any source buffer resize or re-allocation, that would be acceptable. Leaving this unspecified is probably acceptable so that different implementations can do it efficiently. For the sake of the JVM, you might be better off with the following design:

# -> represents a full size allocation (base + size)
IO::Buffer::Allocation = Struct.new(:base, :size)

IO::Buffer = Struct.new(:allocation, :offset, :size)

Then every IO::Buffer is effectively a slice, with the root slice having offset = 0.

Probably the biggest challenge is that we are trying to expose enough of the underlying system to be efficient, including POSIX semantics, without trying to be overly prescriptive about how it actually works, so that implementation on, e.g. JVM, Wasm, etc is possible. As you said, now is the time to figure out if there are any semantics that won't work nicely.

Updated by ioquatix (Samuel Williams) 11 days ago Actions #3 [ruby-core:126627]

Also re frozen, I think frozen should be equivalent to locked, which can be a fast path for some operations. I don't think a frozen buffer is immutable, as that's a totally different issue (read only at the OS level) and I'm not even sure something Ruby can enforce e.g. a shared mapped buffer can change even if read only if another process has a read/write view of it.

Updated by Eregon (Benoit Daloze) 9 days ago Actions #4 [ruby-core:126665]

(apologies for the reply partly written by AI, the base argument is mine, and I found a problematic case with raw addresses for slices)

Right, that "every buffer is a slice of an underlying allocation" model is exactly the right framing, it's essentially the offset-based design (a slice over an allocation whose identity is stable even if its backing storage moves), and it's what TruffleRuby and JRuby would naturally implement.

I have thought more about it and I believe a slice should stay valid across a reallocation that moves the source, and that CRuby itself would be better off with offset-based slices, not just leaving it as "implementation-defined" (which is confusing for users by having less clear/undefined semantics).

It's an easy change on the C side, because the validation path already does the work. io_buffer_validate_slice fetches the source's current base and size on every access (RSTRING_GETMEM / rb_io_buffer_get_bytes) and literally computes offset = slice_base - source_base to bounds-check. So the absolute base stored in the slice is redundant with source_base + offset, the offset is re-derived on every single access anyway.

If a slice instead stored that offset (relative to the root source) and resolved base = source_base + offset at access/lock time, with the same offset + length <= source_size check, then a resize that relocates the source (realloc, or mremap(..., MREMAP_MAYMOVE) for mapped buffers) keeps the slice valid as long as its range still fits (the most reliable behavior), at the same per-access cost (same source fetch, same arithmetic, just source_base + offset instead of slice_base - source_base).

The more important reason, though, is that address-based validation is silently wrong in a case that offset-based can't hit. Because a slice keeps a fixed absolute pointer and re-derives its offset from the source's current base, any reallocation that lands overlapping but shifted from the old one silently rebinds the slice to a different logical region:

  • Source at 1000, slice(16, 16) stores absolute base 1016.
  • Source is freed and reallocated at 1006 (size 64), overlapping the old range.
  • Now offset = 1016 - 1006 = 26: the slice reports valid? == true but refers to logical offset 26, not 16.

So it's not just the exact-same-address regrow (which preserves the logical offset and is relatively benign), it's any overlapping reallocation, and the offset drifts by the shift amount. This stays in-bounds of the live allocation, so it's memory-safe (no OOB, no crash), but it means get_string reads the wrong region and, worse, set_string/copy through the slice silently scribble over the wrong logical bytes of live data, all while valid? is true.

An offset-based slice is immune by construction: the logical offset is fixed at creation and never re-derived from a possibly-shifted base, so a slice can only ever refer to the region it was created for, or be invalid: it can't rebind.

So rather than leaving invalidation unspecified, I'd suggest the observable contract be: a slice tracks a logical [offset, length) range of its source; while valid it sees exactly those bytes; it becomes invalid (raising InvalidatedError) only when the source is freed or shrunk past it. That's implementable identically on CRuby and JVM implementations, it survives relocation, and it removes the silent shifted-rebind that the raw-pointer design allows.

The "stable address for syscalls" property is unaffected: we still resolve to a concrete base under the lock before handing it to a syscall / io_uring, and the source can't move while locked. Offset-based only changes behavior across resize, which is exactly the case the raw-pointer design gets wrong.

The current docs describe the address-reuse revalidation as intended, commits cc706f3956 and 4d5ae42629, which is the part I'd revisit.

Updated by matz (Yukihiro Matsumoto) 8 days ago Actions #5 [ruby-core:126674]

Two points on the semantics.

Slices. I agree with Eregon that a slice should track a logical [offset, length) range of its source, not an absolute address. With address-based validation, a reallocation that lands overlapping but shifted from the old one leaves the slice valid? while it refers to a different logical region, and a write through it silently damages live data. That is worse than an invalidated slice. The offset-based design cannot hit this case, it costs the same on every access, and it is the model that JRuby and TruffleRuby can implement. Please specify it rather than leaving it implementation-defined.

Freeze. A frozen IO::Buffer that still accepts set_string is surprising. In #22291 I took freezing to mean that the object's own state is immutable, and I would like to keep that meaning. I understand a shared mapping can change from outside, but that is the same as a frozen object holding a reference to a mutable one, and it does not make freezing mean something else. If what you want is a fast path for the locked state, please use a separate name for it instead of freeze.

Matz.

Updated by ioquatix (Samuel Williams) 2 days ago Actions #6

@matz (Yukihiro Matsumoto)

Slices

Implemented in https://github.com/ruby/ruby/pull/18911

Freeze

freezing to mean that the object's own state is immutable

I agree with that. An IO::Buffer's own state consists of:

struct rb_io_buffer {
    void *base;
    size_t size;

    enum rb_io_buffer_flags flags;
    size_t lock_count;

#if defined(_WIN32)
    HANDLE mapping;
#endif

    VALUE source;
};

Freezing means all these fields cannot change. The value of the memory pointed to by base is technically not the state of the IO::Buffer and it has separate access control via flags and mprotect (e.g. readonly).

For the purpose of sharing IO::Buffer, freezeing this state is sufficient. If a buffer is frozen, it must be treated the same as permanently locked otherwise no operation can be performed safely. If you shared a buffer between two ractors, it would be necessary to freeze it, but in order to do anything useful, you also need to know that it can't be freed or resized during an operation. After freeze, this is always safe (because the intrinsic state of the buffer cannot be changed). So the two concepts are intertwined.

However, there is no reason and no reasonable way to prevent an IO::Buffer's byte store from changing. Ruby's concept of freeze is unable to mean "the bytes will never change". It's also true there are multiple ways for a potentially frozen buffer to see mutations - a shared underlying buffer is one, but a frozen slice of a mutable buffer is another (if you want to prevent writing via a slice, it should be readonly, and if you want to make a slice unable to change it's size/offset/etc you should freeze it). In addition, sharing a buffer between ractors, for example, should not prevent mutation of the underlying bytes (unless they are marked as readonly).

So, I don't think we should confuse freeze and readonly - they are distinct concepts. freeze is for "safe to share this Ruby object" and readonly means the backing buffer cannot be modified by this view. Therefore, I'm against a frozen buffer meaning underlying memory cannot be modified. I agree, set_string on a frozen buffer may be surprising, but there are many such surprising behaviours of freeze in Ruby, and I think mixing these concepts makes IO::Buffer less useful without any real advantage.

Updated by Eregon (Benoit Daloze) about 7 hours ago · Edited Actions #7 [ruby-core:126809]

ioquatix (Samuel Williams) wrote in #note-6:

Implemented in https://github.com/ruby/ruby/pull/18911

Great, this will make semantics match more closely and ensure reliable behavior.

ioquatix (Samuel Williams) wrote in #note-6:

In addition, sharing a buffer between ractors, for example, should not prevent mutation of the underlying bytes (unless they are marked as readonly).

It definitely should, otherwise this is shared mutable state, breaking the actor model's guarantee of isolated state per actor.

Regarding the mmap non-read-only case, how about raising an exception when trying to raise the IO::Buffer for that since it can't be guaranteed?

IO::Buffer is somewhat similar to a binary String, or an Array of bytes, both of these do prevent mutations after freeze.

IOW, I believe IO::Buffer#freeze should change the buffer to make it read-only, similar to other core classes.
I don't think there is any other container class in core which allows mutation of its "elements" after freeze.

Actions

Also available in: PDF Atom