Observed behavior
Reading a freed IO::Buffer does not raise; it silently returns empty data. Verified on current master: ruby 4.1.0dev (2026-07-02T09:24:35Z master 7c69f93d53) +PRISM [arm64-darwin25].
b = IO::Buffer.new(16)
b.free
b.get_string # => "" (no exception)
b2 = IO::Buffer.for("frozen str")
b2.free
b2.get_string # => "" (no exception)
The same holds for buffers created from C via rb_io_buffer_new(ptr, len, RB_IO_BUFFER_EXTERNAL | RB_IO_BUFFER_READONLY) and freed via rb_io_buffer_free, as verified end-to-end through this bridge's stash test (host/fixture.rb, /stash then /stash-read).
Root cause in CRuby
IO::Buffer::InvalidatedError already exists for exactly this concept, but it is never raised for freed buffers (io_buffer.c, master 7c69f93d53):
io_buffer_validate (io_buffer.c:990) performs real validation only for slices (buffer->source != Qnil); any non-slice buffer, including a freed one with base == NULL, unconditionally passes.
io_buffer_get_bytes_for_reading (io_buffer.c:1074) then maps the NULL base to (NULL, 0).
io_buffer_get_string (io_buffer.c:2840) happily builds a string from 0 bytes, so the caller sees "".
Why it matters for NSGI
Buffer invalidation is the bridge's core safety mechanism: request fields are zero-copy views over host-borrowed memory, and the bridge frees every handed-out buffer when the request completes. Memory safety holds today (freeing nulls the base pointer, so no dangling read is possible), but the failure mode for a misbehaving app is wrong:
- An app that stashes a request buffer and reads it later gets
"" back, a silent data bug that can go unnoticed indefinitely.
- Raising
IO::Buffer::InvalidatedError would turn the lifetime violation into an immediately debuggable exception, which is what our SPEC wants to promise app authors ("holding a buffer past the request raises on access").
Proposed upstream improvement
Make io_buffer_validate (or io_buffer_validate_for_reading/_for_writing) treat a freed buffer as invalid, so access raises the existing IO::Buffer::InvalidatedError. A buffer legitimately created with zero length should keep returning empty data; only buffers whose memory was freed/detached after allocation would raise. Since IO::Buffer is explicitly experimental, this behavioral tightening seems fair game.
References
- Bridge invalidation logic:
ext/nsgi/src/lib.rs (protected_invalidate)
- End-to-end proof:
host/src/main.rs (exercise_stash) + host/fixture.rb
Observed behavior
Reading a freed
IO::Bufferdoes not raise; it silently returns empty data. Verified on current master:ruby 4.1.0dev (2026-07-02T09:24:35Z master 7c69f93d53) +PRISM [arm64-darwin25].The same holds for buffers created from C via
rb_io_buffer_new(ptr, len, RB_IO_BUFFER_EXTERNAL | RB_IO_BUFFER_READONLY)and freed viarb_io_buffer_free, as verified end-to-end through this bridge's stash test (host/fixture.rb,/stashthen/stash-read).Root cause in CRuby
IO::Buffer::InvalidatedErroralready exists for exactly this concept, but it is never raised for freed buffers (io_buffer.c, master7c69f93d53):io_buffer_validate(io_buffer.c:990) performs real validation only for slices (buffer->source != Qnil); any non-slice buffer, including a freed one withbase == NULL, unconditionally passes.io_buffer_get_bytes_for_reading(io_buffer.c:1074) then maps the NULL base to(NULL, 0).io_buffer_get_string(io_buffer.c:2840) happily builds a string from 0 bytes, so the caller sees"".Why it matters for NSGI
Buffer invalidation is the bridge's core safety mechanism: request fields are zero-copy views over host-borrowed memory, and the bridge frees every handed-out buffer when the request completes. Memory safety holds today (freeing nulls the base pointer, so no dangling read is possible), but the failure mode for a misbehaving app is wrong:
""back, a silent data bug that can go unnoticed indefinitely.IO::Buffer::InvalidatedErrorwould turn the lifetime violation into an immediately debuggable exception, which is what our SPEC wants to promise app authors ("holding a buffer past the request raises on access").Proposed upstream improvement
Make
io_buffer_validate(orio_buffer_validate_for_reading/_for_writing) treat a freed buffer as invalid, so access raises the existingIO::Buffer::InvalidatedError. A buffer legitimately created with zero length should keep returning empty data; only buffers whose memory was freed/detached after allocation would raise. SinceIO::Bufferis explicitly experimental, this behavioral tightening seems fair game.References
ext/nsgi/src/lib.rs(protected_invalidate)host/src/main.rs(exercise_stash) +host/fixture.rb