From 177ead8d16ba989c9c8cf3776205ecf00116e1fd Mon Sep 17 00:00:00 2001 From: Sutou Kouhei Date: Tue, 25 Aug 2026 05:50:39 +0900 Subject: [PATCH 1/9] Make IO::Buffer MemoryView ready (#17965) `null?` buffer isn't MemoryView available. Not `null?` buffer is MemoryView available. It can be exported as a 1-dimensional contiguous array of bytes. Users can't change the underlying memory of the exporting IO::Buffer while exported MemoryView is using. --- ext/-test-/memory_view/memory_view.c | 200 +++++++++++++++++++--- inits.c | 2 +- io_buffer.c | 89 ++++++++++ test/ruby/test_io_buffer.rb | 246 +++++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 22 deletions(-) diff --git a/ext/-test-/memory_view/memory_view.c b/ext/-test-/memory_view/memory_view.c index 4dee65dd3b9203..a61652eefc77c9 100644 --- a/ext/-test-/memory_view/memory_view.c +++ b/ext/-test-/memory_view/memory_view.c @@ -114,51 +114,85 @@ memory_view_parse_item_format(VALUE mod, VALUE format) } static VALUE -memory_view_get_memory_view_info(VALUE mod, VALUE obj) +memory_view_release_ensure(VALUE data) { - rb_memory_view_t view; + rb_memory_view_t *view = (rb_memory_view_t *)data; + rb_memory_view_release(view); + return Qnil; +} - if (!rb_memory_view_get(obj, &view, 0)) { - return Qnil; - } +static VALUE +memory_view_get_memory_view_info_body(VALUE args) +{ + rb_memory_view_t *view = (rb_memory_view_t *)args; VALUE hash = rb_hash_new(); - rb_hash_aset(hash, sym_obj, view.obj); - rb_hash_aset(hash, sym_byte_size, SSIZET2NUM(view.byte_size)); - rb_hash_aset(hash, sym_readonly, view.readonly ? Qtrue : Qfalse); - rb_hash_aset(hash, sym_format, view.format ? rb_str_new_cstr(view.format) : Qnil); - rb_hash_aset(hash, sym_item_size, SSIZET2NUM(view.item_size)); - rb_hash_aset(hash, sym_ndim, SSIZET2NUM(view.ndim)); - - if (view.shape) { - VALUE shape = rb_ary_new_capa(view.ndim); + rb_hash_aset(hash, sym_obj, view->obj); + rb_hash_aset(hash, sym_byte_size, SSIZET2NUM(view->byte_size)); + rb_hash_aset(hash, sym_readonly, view->readonly ? Qtrue : Qfalse); + rb_hash_aset(hash, sym_format, view->format ? rb_str_new_cstr(view->format) : Qnil); + rb_hash_aset(hash, sym_item_size, SSIZET2NUM(view->item_size)); + rb_hash_aset(hash, sym_ndim, SSIZET2NUM(view->ndim)); + + if (view->shape) { + VALUE shape = rb_ary_new_capa(view->ndim); + ssize_t i; + for (i = 0; i < view->ndim; i++) { + rb_ary_push(shape, SSIZET2NUM(view->shape[i])); + } rb_hash_aset(hash, sym_shape, shape); } else { rb_hash_aset(hash, sym_shape, Qnil); } - if (view.strides) { - VALUE strides = rb_ary_new_capa(view.ndim); + if (view->strides) { + VALUE strides = rb_ary_new_capa(view->ndim); + ssize_t i; + for (i = 0; i < view->ndim; i++) { + rb_ary_push(strides, SSIZET2NUM(view->strides[i])); + } rb_hash_aset(hash, sym_strides, strides); } else { rb_hash_aset(hash, sym_strides, Qnil); } - if (view.sub_offsets) { - VALUE sub_offsets = rb_ary_new_capa(view.ndim); + if (view->sub_offsets) { + VALUE sub_offsets = rb_ary_new_capa(view->ndim); rb_hash_aset(hash, sym_sub_offsets, sub_offsets); } else { rb_hash_aset(hash, sym_sub_offsets, Qnil); } - rb_memory_view_release(&view); - return hash; } +static VALUE +memory_view_get_memory_view_info(int argc, VALUE *args, VALUE mod) +{ + rb_memory_view_t view; + VALUE obj; + VALUE flags; + enum ruby_memory_view_flags view_flags; + + rb_scan_args(argc, args, "11", &obj, &flags); + if (NIL_P(flags)) { + view_flags = RUBY_MEMORY_VIEW_SIMPLE; + } + else { + view_flags = NUM2UINT(flags); + } + + if (!rb_memory_view_get(obj, &view, view_flags)) { + return Qnil; + } + + return rb_ensure(memory_view_get_memory_view_info_body, (VALUE)&view, + memory_view_release_ensure, (VALUE)&view); +} + static VALUE memory_view_is_row_major_contiguous(VALUE mod, VALUE obj) { @@ -281,6 +315,108 @@ memory_view_extract_item_members(VALUE mod, VALUE str, VALUE format) return item; } +typedef struct { + VALUE location; + rb_memory_view_t view; +} memory_view_get_data_args; + +static VALUE +memory_view_get_data_body(VALUE _args) +{ + memory_view_get_data_args *args = (memory_view_get_data_args *)_args; + long beg, len; + + if (RTEST(rb_range_beg_len(args->location, + &beg, + &len, + args->view.byte_size, + 0))) { + const char *content = ((const char *)(args->view.data)) + beg; + return rb_str_new(content, len); + } + else { + const char *content = + ((const char *)(args->view.data)) + NUM2LONG(args->location); + return rb_str_new(content, 1); + } +} + +static VALUE +memory_view_get_data(VALUE mod, VALUE obj, VALUE location) +{ + memory_view_get_data_args args; + args.location = location; + + if (!rb_memory_view_get(obj, &(args.view), 0)) { + return Qnil; + } + + return rb_ensure(memory_view_get_data_body, (VALUE)&args, + memory_view_release_ensure, (VALUE)&(args.view)); +} + +typedef struct { + VALUE offset; + VALUE data; + rb_memory_view_t view; +} memory_view_set_data_args; + +static VALUE +memory_view_set_data_body(VALUE _args) +{ + memory_view_set_data_args *args = (memory_view_set_data_args *)_args; + + if (FIXNUM_P(args->data)) { + ((char *)(args->view.data))[NUM2LONG(args->offset)] = + NUM2LONG(args->data); + } + else { + StringValue(args->data); + memcpy(((char *)(args->view.data)) + NUM2LONG(args->offset), + RSTRING_PTR(args->data), + RSTRING_LEN(args->data)); + } + + return Qtrue; +} + +static VALUE +memory_view_set_data(VALUE mod, VALUE obj, VALUE offset, VALUE data) +{ + memory_view_set_data_args args; + args.offset = offset; + args.data = data; + + if (!rb_memory_view_get(obj, &(args.view), RUBY_MEMORY_VIEW_WRITABLE)) { + return Qnil; + } + + return rb_ensure(memory_view_set_data_body, (VALUE)&args, + memory_view_release_ensure, (VALUE)&(args.view)); +} + +static VALUE +memory_view_get_body(VALUE data) +{ + return rb_yield_values(0); +} + +static VALUE +memory_view_get(VALUE mod, VALUE obj, VALUE flags) +{ + rb_memory_view_t view; + + if (!rb_memory_view_get(obj, &view, NUM2UINT(flags))) { + rb_raise(rb_eArgError, + "Unable to get memory view: " + "object=%+" PRIsVALUE " flags=%+" PRIsVALUE, + obj, flags); + } + + return rb_ensure(memory_view_get_body, Qnil, + memory_view_release_ensure, (VALUE)&view); +} + static VALUE expstr_initialize(VALUE obj, VALUE s) { @@ -416,16 +552,38 @@ Init_memory_view(void) #ifdef HAVE_RUBY_MEMORY_VIEW_H VALUE mMemoryViewTestUtils = rb_define_module("MemoryViewTestUtils"); + rb_define_const(mMemoryViewTestUtils, "SIMPLE", + UINT2NUM(RUBY_MEMORY_VIEW_SIMPLE)); + rb_define_const(mMemoryViewTestUtils, "WRITABLE", + UINT2NUM(RUBY_MEMORY_VIEW_WRITABLE)); + rb_define_const(mMemoryViewTestUtils, "FORMAT", + UINT2NUM(RUBY_MEMORY_VIEW_FORMAT)); + rb_define_const(mMemoryViewTestUtils, "MULTI_DIMENSIONAL", + UINT2NUM(RUBY_MEMORY_VIEW_MULTI_DIMENSIONAL)); + rb_define_const(mMemoryViewTestUtils, "STRIDES", + UINT2NUM(RUBY_MEMORY_VIEW_STRIDES)); + rb_define_const(mMemoryViewTestUtils, "ROW_MAJOR", + UINT2NUM(RUBY_MEMORY_VIEW_ROW_MAJOR)); + rb_define_const(mMemoryViewTestUtils, "COLUMN_MAJOR", + UINT2NUM(RUBY_MEMORY_VIEW_COLUMN_MAJOR)); + rb_define_const(mMemoryViewTestUtils, "ANY_CONTIGUOUS", + UINT2NUM(RUBY_MEMORY_VIEW_ANY_CONTIGUOUS)); + rb_define_const(mMemoryViewTestUtils, "INDIRECT", + UINT2NUM(RUBY_MEMORY_VIEW_INDIRECT)); + rb_define_module_function(mMemoryViewTestUtils, "available?", memory_view_available_p, 1); rb_define_module_function(mMemoryViewTestUtils, "register", memory_view_register, 1); rb_define_module_function(mMemoryViewTestUtils, "item_size_from_format", memory_view_item_size_from_format, 1); rb_define_module_function(mMemoryViewTestUtils, "parse_item_format", memory_view_parse_item_format, 1); - rb_define_module_function(mMemoryViewTestUtils, "get_memory_view_info", memory_view_get_memory_view_info, 1); + rb_define_module_function(mMemoryViewTestUtils, "get_memory_view_info", memory_view_get_memory_view_info, -1); rb_define_module_function(mMemoryViewTestUtils, "is_row_major_contiguous", memory_view_is_row_major_contiguous, 1); rb_define_module_function(mMemoryViewTestUtils, "is_column_major_contiguous", memory_view_is_column_major_contiguous, 1); rb_define_module_function(mMemoryViewTestUtils, "fill_contiguous_strides", memory_view_fill_contiguous_strides, 4); rb_define_module_function(mMemoryViewTestUtils, "ref_count_while_exporting", memory_view_ref_count_while_exporting, 2); rb_define_module_function(mMemoryViewTestUtils, "extract_item_members", memory_view_extract_item_members, 2); + rb_define_module_function(mMemoryViewTestUtils, "get_data", memory_view_get_data, 2); + rb_define_module_function(mMemoryViewTestUtils, "set_data", memory_view_set_data, 3); + rb_define_module_function(mMemoryViewTestUtils, "get", memory_view_get, 2); VALUE cExportableString = rb_define_class_under(mMemoryViewTestUtils, "ExportableString", rb_cObject); rb_define_method(cExportableString, "initialize", expstr_initialize, 1); diff --git a/inits.c b/inits.c index 1be4916e87b357..e4323cc4a847b6 100644 --- a/inits.c +++ b/inits.c @@ -47,6 +47,7 @@ rb_call_inits(void) CALL(marshal); CALL(Range); CALL(IO); + CALL(MemoryView); /* Must precede IO_Buffer */ CALL(IO_Buffer) CALL(Dir); CALL(Time); @@ -70,7 +71,6 @@ rb_call_inits(void) CALL(process); CALL(Rational); CALL(Complex); - CALL(MemoryView); CALL(pathname); CALL(version); CALL(vm_trace); diff --git a/io_buffer.c b/io_buffer.c index 5a5128aa1dd949..8099c23a37e6fe 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -8,6 +8,7 @@ #include "ruby/io/buffer.h" #include "ruby/fiber/scheduler.h" +#include "ruby/memory_view.h" // For `rb_nogvl`. #include "ruby/thread.h" @@ -4252,6 +4253,81 @@ io_buffer_bit_count(int argc, VALUE *argv, VALUE self) return SIZET2NUM(count); } +static bool +io_buffer_memory_view_get(VALUE self, rb_memory_view_t *view, int flags) +{ + struct rb_io_buffer *buffer = get_io_buffer(self); + + if (buffer->base == NULL || !io_buffer_validate(buffer)) { + return false; + } + + bool readonly = true; + if (flags & RUBY_MEMORY_VIEW_WRITABLE) { + if (io_buffer_readonly_p(buffer)) { + return false; + } else { + readonly = false; + } + } + rb_memory_view_init_as_byte_array(view, self, buffer->base, buffer->size, readonly); + if (flags & RUBY_MEMORY_VIEW_FORMAT) { + view->format = "C"; + } + bool request_multi_dimensional = flags & RUBY_MEMORY_VIEW_MULTI_DIMENSIONAL; + bool request_strides = + (flags & RUBY_MEMORY_VIEW_STRIDES) == RUBY_MEMORY_VIEW_STRIDES; + if (request_multi_dimensional || request_strides) { + size_t n_metadata = 0; + if (request_multi_dimensional) + n_metadata++; + if (request_strides) + n_metadata++; + ssize_t *metadata_buffer = ALLOC_N(ssize_t, n_metadata); + size_t i = 0; + if (request_multi_dimensional) { + ssize_t *shape = &metadata_buffer[i]; + shape[0] = buffer->size; + view->shape = shape; + i++; + } + if (request_strides) { + ssize_t *strides = &metadata_buffer[i]; + strides[0] = 1; + view->strides = strides; + i++; + } + view->private_data = metadata_buffer; + } + io_buffer_lock(buffer); + + return true; +} + +static bool +io_buffer_memory_view_release(VALUE self, rb_memory_view_t *view) +{ + rb_io_buffer_unlock(self); + if (view->private_data) { + xfree(view->private_data); + } + return true; +} + +static bool +io_buffer_memory_view_available_p(VALUE self) +{ + struct rb_io_buffer *buffer = get_io_buffer(self); + + return buffer->base != NULL && io_buffer_validate(buffer); +} + +static const rb_memory_view_entry_t io_buffer_memory_view_entry = { + .get_func = io_buffer_memory_view_get, + .release_func = io_buffer_memory_view_release, + .available_p_func = io_buffer_memory_view_available_p, +}; + /* * Document-class: IO::Buffer * @@ -4276,6 +4352,16 @@ io_buffer_bit_count(int argc, VALUE *argv, VALUE self) * like Fiber::Scheduler#io_read and Fiber::Scheduler#io_write and parsing binary * protocols. * + * == MemoryView Support + * + * IO::Buffer supports the C-level MemoryView protocol, so C + * extensions can use +rb_memory_view_get()+ to access the buffer's + * memory directly (zero-copy) as a 1-dimensional contiguous array of + * bytes. The memory view is writable if the buffer is not + * #readonly? and +RUBY_MEMORY_VIEW_WRITABLE+ is specified. + * + * While a MemoryView is exported, the buffer is locked. + * * == Examples of Usage * * Empty buffer: @@ -4516,4 +4602,7 @@ Init_IO_Buffer(void) rb_define_method(rb_cIOBuffer, "pread", io_buffer_pread, -1); rb_define_method(rb_cIOBuffer, "write", io_buffer_write, -1); rb_define_method(rb_cIOBuffer, "pwrite", io_buffer_pwrite, -1); + + // MemoryView: + rb_memory_view_register(rb_cIOBuffer, &io_buffer_memory_view_entry); } diff --git a/test/ruby/test_io_buffer.rb b/test/ruby/test_io_buffer.rb index 500ca5b7eb0b56..6e0c858fff52a6 100644 --- a/test/ruby/test_io_buffer.rb +++ b/test/ruby/test_io_buffer.rb @@ -4,6 +4,7 @@ require 'rbconfig/sizeof' require 'io/nonblock' require '-test-/io_buffer' +require "-test-/memory_view" class TestIOBuffer < Test::Unit::TestCase experimental = Warning[:experimental] @@ -1703,4 +1704,249 @@ def test_hexdump_width_zero buffer.hexdump(0, 1, 0) end end + + def test_memory_view_null + buffer = IO::Buffer.new(0) + assert_false(MemoryViewTestUtils.available?(buffer)) + end + + def test_memory_view_available + buffer = IO::Buffer.new(8) + assert_true(MemoryViewTestUtils.available?(buffer)) + end + + def test_memory_view_get_simple + data = +"\x00\x01\x02\x03" + IO::Buffer.for(data) do |buffer| + info = MemoryViewTestUtils.get_memory_view_info(buffer) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: nil, + strides: nil, + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_writable + data = +"\x00\x01\x02\x03" + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::WRITABLE + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: false, + format: nil, + item_size: 1, + ndim: 1, + shape: nil, + strides: nil, + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_format + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::FORMAT + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: "C", + item_size: 1, + ndim: 1, + shape: nil, + strides: nil, + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_multi_dimensional + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::MULTI_DIMENSIONAL + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: nil, + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_strides + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::STRIDES + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: [1], + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_row_major + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::ROW_MAJOR + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: [1], + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_column_major + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::COLUMN_MAJOR + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: [1], + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_any_contiguous + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::ANY_CONTIGUOUS + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: [1], + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_get_indirect + data = "\x00\x01\x02\x03".freeze + IO::Buffer.for(data) do |buffer| + flags = MemoryViewTestUtils::INDIRECT + info = MemoryViewTestUtils.get_memory_view_info(buffer, flags) + assert_equal({ + obj: buffer, + byte_size: data.bytesize, + readonly: true, + format: nil, + item_size: 1, + ndim: 1, + shape: [data.bytesize], + strides: [1], + sub_offsets: nil, + }, + info) + end + end + + def test_memory_view_readonly + data = "\x00\x01\x02\x03".freeze + buffer = IO::Buffer.for(data) + # rb_memory_view_get(RUBY_MEMORY_VIEW_WRITABLE) is failed with + # readonly IO::Buffer. + assert_nil(MemoryViewTestUtils.set_data(buffer, 1, 0x11)) + assert_equal(data, MemoryViewTestUtils.get_data(buffer, 0..(data.bytesize))) + end + + def test_memory_view_writable + IO::Buffer.for(+"\x00\x01\x02\x03") do |buffer| + assert_true(MemoryViewTestUtils.set_data(buffer, 1, 0x11)) + assert_equal(0x11, buffer.get_value(:U8, 1)) + end + end + + def test_memory_view_locked + IO::Buffer.for("\x00\x01\x02\x03".freeze) do |buffer| + flags = MemoryViewTestUtils::SIMPLE + MemoryViewTestUtils.get(buffer, flags) do + assert_predicate buffer, :locked? + end + refute_predicate buffer, :locked? + end + end + + def test_memory_view_nested_locked + IO::Buffer.for("\x00\x01\x02\x03".freeze) do |buffer| + flags = MemoryViewTestUtils::SIMPLE + MemoryViewTestUtils.get(buffer, flags) do + MemoryViewTestUtils.get(buffer, flags) do + assert_predicate buffer, :locked? + end + assert_predicate buffer, :locked? + end + refute_predicate buffer, :locked? + end + end + + def test_memory_view_sliced_nested_locked + IO::Buffer.for("\x00\x01\x02\x03".freeze) do |buffer| + sliced = buffer.slice(1, 2) + flags = MemoryViewTestUtils::SIMPLE + MemoryViewTestUtils.get(sliced, flags) do + MemoryViewTestUtils.get(sliced, flags) do + assert_predicate sliced, :locked? + assert_predicate buffer, :locked? + end + assert_predicate sliced, :locked? + assert_predicate buffer, :locked? + end + refute_predicate sliced, :locked? + refute_predicate buffer, :locked? + end + end end From c7018a38c25ddf2012ce21d32eea66813737f97f Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 24 Aug 2026 10:17:37 +0200 Subject: [PATCH 2/9] Hash#rehash: right-size the temporary ar_table --- hash.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hash.c b/hash.c index eede4d192108be..44d9b88ccda564 100644 --- a/hash.c +++ b/hash.c @@ -2123,7 +2123,7 @@ rb_hash_rehash(VALUE hash) } rb_hash_modify_check(hash); if (RHASH_AR_TABLE_P(hash)) { - tmp = hash_alloc(0); + tmp = hash_alloc_capa(0, 0, Qnil, RHASH_SIZE(hash), false); rb_hash_foreach(hash, rb_hash_rehash_i, (VALUE)tmp); hash_ar_free_and_clear_table(hash); From 457029612fc590920424b79809024a20882aaf6e Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 24 Aug 2026 18:14:59 +0200 Subject: [PATCH 3/9] Inverse `hash_alloc` and `hash_alloc_capa` naming --- hash.c | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/hash.c b/hash.c index 44d9b88ccda564..471d46049bc1eb 100644 --- a/hash.c +++ b/hash.c @@ -1497,7 +1497,7 @@ hash_slot_size(size_t capa, bool frozen) } static VALUE -hash_alloc_capa(VALUE klass, VALUE flags, VALUE ifnone, size_t size, bool frozen) +hash_alloc(VALUE klass, VALUE flags, VALUE ifnone, size_t size, bool frozen) { VALUE hash = rb_newobj_of(klass, T_HASH | flags, hash_slot_size(size, frozen)); rb_hash_set_ifnone(hash, ifnone); @@ -1524,19 +1524,19 @@ hash_init_capa(VALUE hash, size_t size) static VALUE hash_hidden_new(size_t size) { - return hash_init_capa(hash_alloc_capa(0, 0, Qnil, size, false), size); + return hash_init_capa(hash_alloc(0, 0, Qnil, size, false), size); } -VALUE -rb_hash_alloc_copy(VALUE klass, VALUE src) +static VALUE +hash_alloc_capa(VALUE klass, size_t size) { - return hash_alloc_capa(klass, 0, Qnil, RHASH_SIZE(src), false); + return hash_alloc(klass, 0, Qnil, size, false); } -static VALUE -hash_alloc(VALUE klass) +VALUE +rb_hash_alloc_copy(VALUE klass, VALUE src) { - return hash_alloc_capa(klass, 0, Qnil, 0, false); + return hash_alloc_capa(klass, RHASH_SIZE(src)); } #if USE_ZJIT @@ -1553,7 +1553,7 @@ empty_hash_alloc(VALUE klass) { RUBY_DTRACE_CREATE_HOOK(HASH, 0); - return hash_alloc(klass); + return hash_alloc_capa(klass, 0); } static VALUE @@ -1568,7 +1568,7 @@ copy_compare_by_id(VALUE hash, VALUE basis) static VALUE hash_new_capa(VALUE klass, size_t capa) { - return hash_init_capa(hash_alloc_capa(klass, 0, Qnil, capa, false), capa); + return hash_init_capa(hash_alloc_capa(klass, capa), capa); } VALUE @@ -1589,7 +1589,7 @@ rb_hash_new(void) VALUE rb_hash_alloc_fixed_size(VALUE klass, st_index_t size) { - return hash_init_capa(hash_alloc_capa(klass, 0, Qnil, size, true), size); + return hash_init_capa(hash_alloc(klass, 0, Qnil, size, true), size); } static VALUE @@ -1635,7 +1635,7 @@ hash_copy(VALUE ret, VALUE hash) static VALUE hash_dup_with_compare_by_id(VALUE hash) { - VALUE dup = hash_alloc_capa(rb_cHash, 0, Qnil, RHASH_SIZE(hash), false); + VALUE dup = hash_alloc_capa(rb_cHash, RHASH_SIZE(hash)); if (RHASH_ST_TABLE_P(hash)) { RHASH_SET_ST_FLAG(dup); } @@ -1646,14 +1646,14 @@ hash_dup_with_compare_by_id(VALUE hash) static VALUE hash_dup(VALUE hash, VALUE klass, VALUE flags) { - VALUE dup = hash_alloc_capa(klass, flags, RHASH_IFNONE(hash), RHASH_SIZE(hash), false); + VALUE dup = hash_alloc(klass, flags, RHASH_IFNONE(hash), RHASH_SIZE(hash), false); return hash_copy(dup, hash); } static VALUE hash_dup_capa(VALUE hash, size_t capa) { - VALUE ret = hash_alloc_capa(rb_cHash, 0, Qnil, capa, false); + VALUE ret = hash_alloc_capa(rb_cHash, capa); if (capa > RHASH_AR_TABLE_MAX_SIZE) { RHASH_SET_ST_FLAG(ret); } @@ -1933,7 +1933,7 @@ rb_hash_s_create(int argc, VALUE *argv, VALUE klass) tmp = rb_hash_to_a(tmp); } else { - hash = hash_alloc_capa(klass, 0, Qnil, RHASH_SIZE(tmp), false); + hash = hash_alloc_capa(klass, RHASH_SIZE(tmp)); return hash_copy(hash, tmp); } } @@ -2123,7 +2123,7 @@ rb_hash_rehash(VALUE hash) } rb_hash_modify_check(hash); if (RHASH_AR_TABLE_P(hash)) { - tmp = hash_alloc_capa(0, 0, Qnil, RHASH_SIZE(hash), false); + tmp = hash_alloc_capa(0, RHASH_SIZE(hash)); rb_hash_foreach(hash, rb_hash_rehash_i, (VALUE)tmp); hash_ar_free_and_clear_table(hash); @@ -2131,7 +2131,7 @@ rb_hash_rehash(VALUE hash) } else if (RHASH_ST_TABLE_P(hash)) { st_table *old_tab = RHASH_ST_TABLE(hash); - tmp = hash_alloc(0); + tmp = hash_alloc_capa(0, 0); hash_st_table_init(tmp, old_tab->type, old_tab->num_entries); tbl = RHASH_ST_TABLE(tmp); @@ -4813,7 +4813,7 @@ rb_hash_compare_by_id(VALUE hash) else { // Slow path: Need to rehash the members of `self` into a new // `tmp` table using the new `identhash` compare/hash functions. - tmp = hash_alloc(0); + tmp = hash_alloc_capa(0, 0); hash_st_table_init(tmp, &identhash, RHASH_SIZE(hash)); identtable = RHASH_ST_TABLE(tmp); From 05d58bd8246cf5d38dd08d9b19ac72c041e328ff Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 17:26:17 +0900 Subject: [PATCH 4/9] [ruby/rubygems] Print a release notes link instead of the whole changelog on `gem update --system` CHANGELOG.md is over 7,000 lines of generated PR titles, and printing every entry since the previous version dominated the output (478 of the 499 lines on the ruby:3.4 image). Print a single line linking the versioned CHANGELOG.md instead. setup.rb keeps accepting --previous-version so the message can name the range to read. The link falls back to master for .dev versions, which have no tag, and the since clause is dropped when the value is missing, invalid, or not older than the installed version. Fixes https://github.com/ruby/rubygems/pull/9789 https://github.com/ruby/rubygems/commit/0fb09692be Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/setup_command.rb | 47 ++----- .../test_gem_commands_setup_command.rb | 133 ++++++++++++++---- 2 files changed, 113 insertions(+), 67 deletions(-) diff --git a/lib/rubygems/commands/setup_command.rb b/lib/rubygems/commands/setup_command.rb index 1b90b4f63ae706..10996374218c50 100644 --- a/lib/rubygems/commands/setup_command.rb +++ b/lib/rubygems/commands/setup_command.rb @@ -7,9 +7,6 @@ # RubyGems checkout or tarball. class Gem::Commands::SetupCommand < Gem::Command - HISTORY_HEADER = %r{^##\s*[\d.a-zA-Z]+\s*/\s*\d{4}-\d{2}-\d{2}\s*$} - VERSION_MATCHER = %r{^##\s*([\d.a-zA-Z]+)\s*/\s*\d{4}-\d{2}-\d{2}\s*$} - ENV_PATHS = %w[/usr/bin/env /bin/env].freeze def initialize @@ -23,7 +20,7 @@ def initialize add_option "--previous-version=VERSION", "Previous version of RubyGems", - "Used for changelog processing" do |version, options| + "Used for the release notes link" do |version, options| options[:previous_version] = version end @@ -183,12 +180,6 @@ def execute say end - if options[:previous_version].empty? - options[:previous_version] = Gem::VERSION.sub(/[0-9]+$/, "0") - end - - options[:previous_version] = Gem::Version.new(options[:previous_version]) - show_release_notes say @@ -549,34 +540,16 @@ def remove_old_man_files(old_man_dir) end def show_release_notes - release_notes = File.join Dir.pwd, "CHANGELOG.md" - - release_notes = - if File.exist? release_notes - history = File.read release_notes - - history.force_encoding Encoding::UTF_8 - - text = history.split(HISTORY_HEADER) - text.shift # correct an off-by-one generated by split - version_lines = history.scan(HISTORY_HEADER) - versions = history.scan(VERSION_MATCHER).flatten.map do |x| - Gem::Version.new(x) - end + ref = Gem::VERSION.include?(".dev") ? "master" : "v#{Gem::VERSION}" + link = "https://github.com/ruby/rubygems/blob/#{ref}/CHANGELOG.md" - history_string = "" - - until versions.length == 0 || - versions.shift <= options[:previous_version] do - history_string += version_lines.shift + text.shift - end - - history_string - else - "Oh-no! Unable to find release notes!" - end - - say release_notes + previous = options[:previous_version].to_s.strip + if previous.empty? || !Gem::Version.correct?(previous) || + Gem::Version.new(previous) >= Gem::Version.new(Gem::VERSION) + say "See #{link} for the changes." + else + say "See #{link} for the changes since #{previous}." + end end def uninstall_old_gemcutter diff --git a/test/rubygems/test_gem_commands_setup_command.rb b/test/rubygems/test_gem_commands_setup_command.rb index ba410d64e635cd..484a802fe536e4 100644 --- a/test/rubygems/test_gem_commands_setup_command.rb +++ b/test/rubygems/test_gem_commands_setup_command.rb @@ -407,56 +407,129 @@ def test_remove_old_man_files end def test_show_release_notes - @default_external = @ui.outs.external_encoding - @ui.outs.set_encoding Encoding::US_ASCII + @cmd.options[:previous_version] = "2.0.2" - @cmd.options[:previous_version] = Gem::Version.new "2.0.2" + with_gem_version "4.0.19" do + use_ui @ui do + @cmd.show_release_notes + end + end - File.open "CHANGELOG.md", "w" do |io| - io.puts <<-HISTORY_TXT -# Changelog + expected = "See https://github.com/ruby/rubygems/blob/v4.0.19/CHANGELOG.md for the changes since 2.0.2.\n" -## #{Gem::VERSION} / 2013-03-26 + assert_equal expected, @ui.output + end -### Bug fixes: - * Fixed release note display for LANG=C when installing rubygems - * π is tasty + def test_show_release_notes_dev_version + @cmd.options[:previous_version] = "2.0.2" -## 2.0.2 / 2013-03-06 + with_gem_version "4.1.0.dev" do + use_ui @ui do + @cmd.show_release_notes + end + end -### Bug fixes: - * Other bugs fixed + expected = "See https://github.com/ruby/rubygems/blob/master/CHANGELOG.md for the changes since 2.0.2.\n" -## 2.0.1 / 2013-03-05 + assert_equal expected, @ui.output + end -### Bug fixes: - * Yet more bugs fixed - HISTORY_TXT + def test_show_release_notes_released_prerelease + @cmd.options[:previous_version] = "2.0.2" + + with_gem_version "4.1.0.beta1" do + use_ui @ui do + @cmd.show_release_notes + end end - use_ui @ui do - @cmd.show_release_notes + expected = "See https://github.com/ruby/rubygems/blob/v4.1.0.beta1/CHANGELOG.md for the changes since 2.0.2.\n" + + assert_equal expected, @ui.output + end + + def test_show_release_notes_downgrade + @cmd.handle_options ["--previous-version", "4.0.19"] + + with_gem_version "4.0.10" do + use_ui @ui do + @cmd.show_release_notes + end end - expected = <<-EXPECTED -## #{Gem::VERSION} / 2013-03-26 + expected = "See https://github.com/ruby/rubygems/blob/v4.0.10/CHANGELOG.md for the changes.\n" -### Bug fixes: - * Fixed release note display for LANG=C when installing rubygems - * π is tasty + assert_equal expected, @ui.output + end - EXPECTED + def test_show_release_notes_same_version + @cmd.handle_options ["--previous-version", "4.0.19"] - output = @ui.output - output.force_encoding Encoding::UTF_8 + with_gem_version "4.0.19" do + use_ui @ui do + @cmd.show_release_notes + end + end - assert_equal expected, output - ensure - @ui.outs.set_encoding @default_external if @default_external + expected = "See https://github.com/ruby/rubygems/blob/v4.0.19/CHANGELOG.md for the changes.\n" + + assert_equal expected, @ui.output + end + + def test_show_release_notes_whitespace_previous_version + @cmd.handle_options ["--previous-version", " 2.0.2\r"] + + with_gem_version "4.0.19" do + use_ui @ui do + @cmd.show_release_notes + end + end + + expected = "See https://github.com/ruby/rubygems/blob/v4.0.19/CHANGELOG.md for the changes since 2.0.2.\n" + + assert_equal expected, @ui.output + end + + def test_show_release_notes_invalid_previous_version + @cmd.handle_options ["--previous-version", "\e[31mnot-a-version"] + + with_gem_version "4.0.19" do + use_ui @ui do + @cmd.show_release_notes + end + end + + expected = "See https://github.com/ruby/rubygems/blob/v4.0.19/CHANGELOG.md for the changes.\n" + + assert_equal expected, @ui.output + end + + def test_show_release_notes_without_previous_version + @cmd.options[:previous_version] = "" + + with_gem_version "4.0.19" do + use_ui @ui do + @cmd.show_release_notes + end + end + + expected = "See https://github.com/ruby/rubygems/blob/v4.0.19/CHANGELOG.md for the changes.\n" + + assert_equal expected, @ui.output end private + def with_gem_version(version) + original = Gem::VERSION + Gem.send :remove_const, :VERSION + Gem.const_set :VERSION, version + yield + ensure + Gem.send :remove_const, :VERSION + Gem.const_set :VERSION, original + end + def create_dummy_files(list) list.each do |file| FileUtils.mkdir_p File.dirname(file) From ff808c667ec5eca1b9ded0d346b2749781d90b62 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 17:26:17 +0900 Subject: [PATCH 5/9] [ruby/rubygems] Add a release notes link to the update suggestion With the changelog dump gone, the pre-update notice is where users decide whether to update, so link the versioned CHANGELOG.md there too, matching npm's three-line notice. https://github.com/ruby/rubygems/commit/4ca3c9eba1 Co-Authored-By: Claude Fable 5 --- lib/rubygems/update_suggestion.rb | 8 ++++++-- test/rubygems/test_gem_update_suggestion.rb | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/update_suggestion.rb b/lib/rubygems/update_suggestion.rb index 6f3ec5f4936747..563f569c2475db 100644 --- a/lib/rubygems/update_suggestion.rb +++ b/lib/rubygems/update_suggestion.rb @@ -10,10 +10,14 @@ module Gem::UpdateSuggestion # Message to promote available RubyGems update with related gem update command. def update_suggestion + current = Gem.rubygems_version + latest = Gem.latest_rubygems_version + <<-MESSAGE -A new release of RubyGems is available: #{Gem.rubygems_version} → #{Gem.latest_rubygems_version}! -Run `gem update --system #{Gem.latest_rubygems_version}` to update your installation. +A new release of RubyGems is available: #{current} → #{latest}! +See https://github.com/ruby/rubygems/blob/v#{latest}/CHANGELOG.md for the changes since #{current}. +Run `gem update --system #{latest}` to update your installation. MESSAGE end diff --git a/test/rubygems/test_gem_update_suggestion.rb b/test/rubygems/test_gem_update_suggestion.rb index 8cb8ee57ff90be..dc5395f8344b79 100644 --- a/test/rubygems/test_gem_update_suggestion.rb +++ b/test/rubygems/test_gem_update_suggestion.rb @@ -55,9 +55,10 @@ def self.with_eligible_environment( def test_update_suggestion Gem.stub :rubygems_version, Gem::Version.new("1.2.3") do Gem.stub :latest_rubygems_version, Gem::Version.new("2.0.0") do - assert_equal @cmd.update_suggestion, <<~SUGGESTION + assert_equal <<~SUGGESTION, @cmd.update_suggestion A new release of RubyGems is available: 1.2.3 → 2.0.0! + See https://github.com/ruby/rubygems/blob/v2.0.0/CHANGELOG.md for the changes since 1.2.3. Run `gem update --system 2.0.0` to update your installation. SUGGESTION From 54db2e9559607d85247fbc2e729a4e052fcc542e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 25 Aug 2026 08:00:36 +0900 Subject: [PATCH 6/9] [ruby/resolv] Apply the request deadline to TCP frame reads Requester#request computes a monotonic time limit but only the socket readability wait honoured it, so a peer that sends an incomplete frame and keeps the connection open blocked past the configured timeouts until it closed. The parameter defaults to nil, which keeps the previous blocking behaviour for anyone calling recv_reply the old way. https://github.com/ruby/resolv/commit/a9133ef6f3 Co-Authored-By: Claude Opus 5 --- lib/resolv.rb | 36 +++++-- test/resolv/test_dns.rb | 205 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 6 deletions(-) diff --git a/lib/resolv.rb b/lib/resolv.rb index 6b58f92813b435..79e51d9d33d1fc 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -724,7 +724,7 @@ def request(sender, tout) raise ResolvTimeout end begin - reply, from = recv_reply(select_result[0]) + reply, from = recv_reply(select_result[0], timelimit) rescue Errno::ECONNREFUSED, # GNU/Linux, FreeBSD Errno::ECONNRESET, # Windows EOFError @@ -801,7 +801,7 @@ def lazy_initialize self end - def recv_reply(readable_socks) + def recv_reply(readable_socks, timelimit = nil) lazy_initialize reply, from = readable_socks[0].recvfrom(UDPSize) return reply, [from[3],from[1]] @@ -870,7 +870,7 @@ def lazy_initialize self end - def recv_reply(readable_socks) + def recv_reply(readable_socks, timelimit = nil) lazy_initialize reply = readable_socks[0].recv(UDPSize) return reply, nil @@ -935,11 +935,12 @@ def initialize(host, port=Port) @senders = {} end - def recv_reply(readable_socks) - len_data = readable_socks[0].read(2) + def recv_reply(readable_socks, timelimit = nil) + sock = readable_socks[0] + len_data = read_exactly(sock, 2, timelimit) raise EOFError if len_data.nil? || len_data.bytesize != 2 len = len_data.unpack('n')[0] - reply = @socks[0].read(len) + reply = read_exactly(sock, len, timelimit) raise EOFError if reply.nil? || reply.bytesize != len return reply, nil end @@ -968,6 +969,29 @@ def close DNS.free_request_id(@host, @port, id) } end + + private + + # Read +len+ bytes, giving up with ResolvTimeout once +timelimit+ (a + # CLOCK_MONOTONIC value) has passed. A shorter result means the peer + # closed the connection, which the caller turns into an EOFError. + def read_exactly(sock, len, timelimit) + return sock.read(len) unless timelimit + buf = String.new + while buf.bytesize < len + case chunk = sock.read_nonblock(len - buf.bytesize, exception: false) + when :wait_readable + remaining = timelimit - Process.clock_gettime(Process::CLOCK_MONOTONIC) + raise ResolvTimeout if remaining <= 0 + sock.wait_readable(remaining) or raise ResolvTimeout + when nil + break + else + buf << chunk + end + end + buf + end end ## diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb index 0b81118c8c4a48..fbd98680fa7bdb 100644 --- a/test/resolv/test_dns.rb +++ b/test/resolv/test_dns.rb @@ -943,4 +943,209 @@ def test_tcp_connection_closed_with_partial_message_body client_thread.join end end + + def accept_within_timeout(t) + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) { t.accept } + end + + # Reads one length prefixed DNS message from +sock+. + def read_framed_query(sock) + len_data = sock.read(2) + flunk('the client closed the connection before sending a query') unless len_data&.bytesize == 2 + Resolv::DNS::Message.decode(sock.read(len_data.unpack('n')[0])) + end + + # Builds the encoded reply answering +query+ with a single A record. + def reply_for_query(query, address) + reply = Resolv::DNS::Message.new(query.id) + reply.qr = 1 + reply.rd = query.rd + reply.ra = 1 + query.each_question do |name, typeclass| + reply.add_question(name, typeclass) + reply.add_answer(name, 3600, Resolv::DNS::Resource::IN::A.new(address)) + end + reply.encode + end + + def framed(encoded) + [encoded.bytesize].pack('n') << encoded + end + + # Runs a TCP server which replies with +reply+ and then keeps the connection + # open until the client side is done, so that only the timeout can end the + # request. + def with_tcp_server_keeping_connection_open(reply) + with_tcp('127.0.0.1', 0) do |t| + _, server_port, _, server_address = t.addr + done = Thread::Queue.new + + server_thread = Thread.new do + ct = accept_within_timeout(t) + begin + ct.recv(512) + ct.write(reply) + done.pop + ensure + ct.close + end + end + + client_thread = Thread.new do + begin + yield server_address, server_port + ensure + done.push(true) + end + end + + assert_join_threads([client_thread, server_thread]) + end + end + + def request_over_tcp(server_address, server_port, tout) + requester = Resolv::DNS::Requester::TCP.new(server_address, server_port) + begin + msg = Resolv::DNS::Message.new + msg.add_question('example.org', Resolv::DNS::Resource::IN::A) + sender = requester.sender(msg, msg) + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) do + requester.request(sender, EnvUtil.apply_timeout_scale(tout)) + end + ensure + requester.close + end + end + + def test_tcp_partial_length_prefix_kept_open + with_tcp_server_keeping_connection_open("\x00") do |server_address, server_port| + assert_raise(Resolv::ResolvTimeout) do + request_over_tcp(server_address, server_port, 0.5) + end + end + end + + def test_tcp_partial_message_body_kept_open + reply = [10].pack('n') << '12345' # 5 bytes of a 10 byte message + with_tcp_server_keeping_connection_open(reply) do |server_address, server_port| + assert_raise(Resolv::ResolvTimeout) do + request_over_tcp(server_address, server_port, 0.5) + end + end + end + + def test_tcp_complete_reply_kept_open + with_tcp('127.0.0.1', 0) do |t| + _, server_port, _, server_address = t.addr + done = Thread::Queue.new + + server_thread = Thread.new do + ct = accept_within_timeout(t) + begin + ct.write(framed(reply_for_query(read_framed_query(ct), '192.0.2.1'))) + done.pop + ensure + ct.close + end + end + + client_thread = Thread.new do + begin + reply, = request_over_tcp(server_address, server_port, 2) + assert_equal(1, reply.answer.length) + assert_equal('192.0.2.1', reply.answer[0][2].address.to_s) + ensure + done.push(true) + end + end + + assert_join_threads([client_thread, server_thread]) + end + end + + def test_tcp_reply_arriving_in_two_chunks + with_tcp('127.0.0.1', 0) do |t| + _, server_port, _, server_address = t.addr + done = Thread::Queue.new + + server_thread = Thread.new do + ct = accept_within_timeout(t) + begin + reply = framed(reply_for_query(read_framed_query(ct), '192.0.2.1')) + ct.write(reply.byteslice(0, 6)) + sleep EnvUtil.apply_timeout_scale(0.2) + ct.write(reply.byteslice(6..-1)) + done.pop + ensure + ct.close + end + end + + client_thread = Thread.new do + begin + reply, = request_over_tcp(server_address, server_port, 2) + assert_equal(1, reply.answer.length) + assert_equal('192.0.2.1', reply.answer[0][2].address.to_s) + ensure + done.push(true) + end + end + + assert_join_threads([client_thread, server_thread]) + end + end + + def test_truncated_tcp_fallback_with_partial_message_body_kept_open + with_udp_and_tcp('127.0.0.1', 0) do |u, t| + _, server_port, _, server_address = u.addr + done = Thread::Queue.new + + client_thread = Thread.new do + begin + dns = Resolv::DNS.new(nameserver_port: [[server_address, server_port]], + raise_timeout_errors: true) + begin + dns.timeouts = EnvUtil.apply_timeout_scale(0.5) + assert_raise(Resolv::ResolvError) do + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) do + dns.getresources('foo.example.org', Resolv::DNS::Resource::IN::A) + end + end + ensure + dns.close + end + ensure + done.push(true) + end + end + + udp_server_thread = Thread.new do + msg, (_, client_port, _, client_address) = + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) { u.recvfrom(4096) } + id, word2, = msg.unpack('nnnnnn') + opcode = (word2 & 0x7800) >> 11 + rd = (word2 & 0x0100) >> 8 + qr = 1 + tc = 1 # ask the client to retry over TCP + ra = 1 + word2 = (qr << 15) | (opcode << 11) | (tc << 9) | (rd << 8) | (ra << 7) + u.send([id, word2, 0, 0, 0, 0].pack('nnnnnn'), 0, client_address, client_port) + end + + tcp_server_thread = Thread.new do + ct = accept_within_timeout(t) + begin + ct.recv(512) + ct.write([10].pack('n') << '12345') # 5 bytes of a 10 byte message + done.pop + ensure + ct.close + end + end + + assert_join_threads([client_thread, udp_server_thread, tcp_server_thread]) + end + end + + end From 4ea554661a82617f6096b079831c224be4cfea84 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 25 Aug 2026 08:01:09 +0900 Subject: [PATCH 7/9] [ruby/resolv] Drop a TCP connection that cannot carry another request Giving up part way through a frame leaves the stream between frame boundaries, so reusing the cached requester made every later attempt read the previous frame's bytes as a length prefix. Asking the requester instead of its class keeps a connection whose stream is still on a boundary, which a plain timeout has no reason to throw away. https://github.com/ruby/resolv/commit/791447b93f Co-Authored-By: Claude Opus 5 --- lib/resolv.rb | 40 ++++++++++++++- test/resolv/test_dns.rb | 107 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/lib/resolv.rb b/lib/resolv.rb index 79e51d9d33d1fc..03e5e9a75dfa72 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -562,7 +562,21 @@ def fetch_resource(name, typeclass) next if !sender senders[[candidate, requester, nameserver, port]] = sender end - reply, reply_name = requester.request(sender, tout) + begin + reply, reply_name = requester.request(sender, tout) + rescue ResolvTimeout + # Giving up part way through a frame loses stream sync, and a peer + # seen going away leaves the socket dead. Either way the requester + # says so, and the next attempt has to open a fresh connection. A + # timeout with the stream still on a frame boundary keeps it; a + # peer that leaves while nothing is being read goes unnoticed here + # and only shows up when the next request is written. + unless requester.reusable? + requesters.delete([nameserver, port]) + requester.close + end + raise + end case reply.rcode when RCode::NoError if reply.tc == 1 and not Requester::TCP === requester @@ -698,6 +712,12 @@ def initialize @socks = nil end + # Whether another request may be sent over the same transport. Only a + # stream transport can end up in a state that rules this out. + def reusable? + true + end + def request(sender, tout) start = Process.clock_gettime(Process::CLOCK_MONOTONIC) timelimit = start + tout @@ -933,6 +953,11 @@ def initialize(host, port=Port) sock = TCPSocket.new(@host, @port) @socks = [sock] @senders = {} + @reusable = true + end + + def reusable? + @reusable end def recv_reply(readable_socks, timelimit = nil) @@ -942,7 +967,14 @@ def recv_reply(readable_socks, timelimit = nil) len = len_data.unpack('n')[0] reply = read_exactly(sock, len, timelimit) raise EOFError if reply.nil? || reply.bytesize != len + @reusable = true return reply, nil + rescue EOFError, SystemCallError + # Whatever the kernel reported, this socket cannot be trusted for + # another frame. In practice that is the peer closing or resetting; + # the rest is rare enough that erring towards reconnecting is right. + @reusable = false + raise end def sender(msg, data, host=@host, port=@port) @@ -964,6 +996,7 @@ def send end def close + @reusable = false super @senders.each_key {|from,id| DNS.free_request_id(@host, @port, id) @@ -975,6 +1008,10 @@ def close # Read +len+ bytes, giving up with ResolvTimeout once +timelimit+ (a # CLOCK_MONOTONIC value) has passed. A shorter result means the peer # closed the connection, which the caller turns into an EOFError. + # Consuming any byte marks the requester unusable until the whole frame + # has been read, since giving up in between loses frame sync. Without a + # +timelimit+ the read blocks instead and keeps no such mark; that is + # only for a caller still using the one argument form of #recv_reply. def read_exactly(sock, len, timelimit) return sock.read(len) unless timelimit buf = String.new @@ -987,6 +1024,7 @@ def read_exactly(sock, len, timelimit) when nil break else + @reusable = false buf << chunk end end diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb index fbd98680fa7bdb..b4ef92b63845f9 100644 --- a/test/resolv/test_dns.rb +++ b/test/resolv/test_dns.rb @@ -843,6 +843,8 @@ def test_tcp_connection_closed_before_length assert_raise(Resolv::ResolvTimeout) do requester.request(sender, 2) end + # The peer went away, so this connection cannot carry another request. + assert_equal(false, requester.reusable?) ensure requester.close end @@ -1147,5 +1149,110 @@ def test_truncated_tcp_fallback_with_partial_message_body_kept_open end end + # A frame read that gives up part way through a frame leaves the stream + # between frame boundaries, so the retry has to start from a new connection. + def test_truncated_tcp_fallback_retries_on_a_new_connection + with_udp_and_tcp('127.0.0.1', 0) do |u, t| + _, server_port, _, server_address = u.addr + + client_thread = Thread.new do + Resolv::DNS.open(nameserver_port: [[server_address, server_port]], + raise_timeout_errors: true) do |dns| + dns.timeouts = [EnvUtil.apply_timeout_scale(1), + EnvUtil.apply_timeout_scale(3)] + Timeout.timeout(EnvUtil.apply_timeout_scale(20)) do + dns.getresources('foo.example.org', Resolv::DNS::Resource::IN::A) + end + end + end + + udp_server_thread = Thread.new do + msg, (_, client_port, _, client_address) = + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) { u.recvfrom(4096) } + id, word2, = msg.unpack('nnnnnn') + opcode = (word2 & 0x7800) >> 11 + rd = (word2 & 0x0100) >> 8 + qr = 1 + tc = 1 # ask the client to retry over TCP + ra = 1 + word2 = (qr << 15) | (opcode << 11) | (tc << 9) | (rd << 8) | (ra << 7) + u.send([id, word2, 0, 0, 0, 0].pack('nnnnnn'), 0, client_address, client_port) + end + + tcp_server_thread = Thread.new do + partial = accept_within_timeout(t) + begin + read_framed_query(partial) + partial.write([45].pack('n') << 'abcdef') # 6 bytes of a 45 byte message + complete = accept_within_timeout(t) + begin + complete.write(framed(reply_for_query(read_framed_query(complete), '192.0.2.1'))) + ensure + complete.close + end + ensure + partial.close + end + end + result, = assert_join_threads([client_thread, udp_server_thread, tcp_server_thread]) + assert_equal(['192.0.2.1'], result.map {|rr| rr.address.to_s }) + end + end + + # A timeout with no bytes read leaves the stream on a frame boundary, so the + # retry keeps the connection. The reply below only ever reaches the client if + # the second attempt reuses the socket the first one opened. + def test_truncated_tcp_fallback_keeps_the_connection_when_nothing_arrived + with_udp_and_tcp('127.0.0.1', 0) do |u, t| + _, server_port, _, server_address = u.addr + done = Thread::Queue.new + + client_thread = Thread.new do + begin + Resolv::DNS.open(nameserver_port: [[server_address, server_port]], + raise_timeout_errors: true) do |dns| + dns.timeouts = [EnvUtil.apply_timeout_scale(0.5), + EnvUtil.apply_timeout_scale(5)] + Timeout.timeout(EnvUtil.apply_timeout_scale(20)) do + dns.getresources('foo.example.org', Resolv::DNS::Resource::IN::A) + end + end + ensure + done.push(true) + end + end + + udp_server_thread = Thread.new do + msg, (_, client_port, _, client_address) = + Timeout.timeout(EnvUtil.apply_timeout_scale(10)) { u.recvfrom(4096) } + id, word2, = msg.unpack('nnnnnn') + opcode = (word2 & 0x7800) >> 11 + rd = (word2 & 0x0100) >> 8 + qr = 1 + tc = 1 # ask the client to retry over TCP + ra = 1 + word2 = (qr << 15) | (opcode << 11) | (tc << 9) | (rd << 8) | (ra << 7) + u.send([id, word2, 0, 0, 0, 0].pack('nnnnnn'), 0, client_address, client_port) + end + + tcp_server_thread = Thread.new do + ct = accept_within_timeout(t) + begin + query = read_framed_query(ct) + # Stay silent past the first interval, then answer on this connection. + # It has to stay open until the client is done: the retry resends the + # query, and closing with that still unread would discard the reply. + sleep EnvUtil.apply_timeout_scale(1) + ct.write(framed(reply_for_query(query, '192.0.2.1'))) + done.pop + ensure + ct.close + end + end + + result, = assert_join_threads([client_thread, udp_server_thread, tcp_server_thread]) + assert_equal(['192.0.2.1'], result.map {|rr| rr.address.to_s }) + end + end end From 3d36072a06c4925dde878791c469bd5f366ba689 Mon Sep 17 00:00:00 2001 From: MSP-Greg Date: Mon, 24 Aug 2026 09:34:46 -0500 Subject: [PATCH 8/9] [DOC] Fix `IO.puts` doc --- io.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io.c b/io.c index 5812aa4497d536..f4dcc9883d2f6b 100644 --- a/io.c +++ b/io.c @@ -8966,7 +8966,7 @@ io_puts_ary(VALUE ary, VALUE out, int recur) * If called without arguments, writes a newline. * See {Line IO}[rdoc-ref:IO@Line+IO]. * - * Note that each added newline is the character "\n", + * Note that each added newline is the character "\n", * not the output record separator ($\\). * * Treatment for each object: From 4b8d4626706bac83d7402e9b887d4d1a865a9269 Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Mon, 24 Aug 2026 19:38:42 -0500 Subject: [PATCH 9/9] [DOC] Doc for File::dirname --- file.c | 23 ++++++++++++----------- pathname_builtin.rb | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/file.c b/file.c index d173cfe1858890..0f2cbfc9e64749 100644 --- a/file.c +++ b/file.c @@ -5536,21 +5536,22 @@ static VALUE rb_file_dirname_n(VALUE fname, int n); /* * call-seq: - * File.dirname(file_name, level = 1) -> dir_name + * File.dirname(path, count = 1) -> string * - * Returns all components of the filename given in file_name - * except the last one (after first stripping trailing separators). - * The filename can be formed using both File::SEPARATOR and - * File::ALT_SEPARATOR as the separator when File::ALT_SEPARATOR is - * not nil. + * Returns a string path containing all but the last +count+ components + * of the given +path+: * - * File.dirname("/home/gumby/work/ruby.rb") #=> "/home/gumby/work" + * File.dirname('/usr/lib/linux') # => "/usr/lib" + * File.dirname('/usr') # => "/" + * File.dirname('/') # => "/" + * File.dirname('lib/') # => "." + * File.dirname('nosuch') # => "." + * File.dirname('/usr/lib/linux', 2) # => "/usr" + * File.dirname('/usr/lib/linux', 20) # => "/" + * File.dirname('/usr/lib/linux', 0) # => "/usr/lib/linux" * - * If +level+ is given, removes the last +level+ components, not only - * one. + * Components are delimited by File::SEPARATOR and, if non-+nil+, File::ALT_SEPARATOR. * - * File.dirname("/home/gumby/work/ruby.rb", 2) #=> "/home/gumby" - * File.dirname("/home/gumby/work/ruby.rb", 4) #=> "/" */ static VALUE diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 1471350488409d..6b8108ce0871cc 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -2045,6 +2045,8 @@ def basename(...) self.class.new(File.basename(@path, ...)) end # Pathname('nosuch').basename # => # # ``` # + # Components are delimited by File::SEPARATOR and, if non-+nil+, File::ALT_SEPARATOR. + def dirname() self.class.new(File.dirname(@path)) end # :markup: markdown