From b58bea071d47d44220efd6e71a3d8584963640c3 Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Fri, 25 Sep 2026 16:30:52 -0700 Subject: [PATCH 01/17] Fix buffer size accounting for `rb_str_tmp_frozen` roots capa does not include the terminator, so when a buffer moves between strings with different terminator lengths the capacity has to be converted or we will try to free the buffer with the wrong size: [BUG] buffer 0x00005030039ff768 freed with old_size=18, but was allocated with size=17 [BUG] buffer 0x00005631adc48f48 freed with old_size=8268, but was allocated with size=8269 Three related fixes: * `rb_str_tmp_frozen_release`: convert tmp's capacity to orig's terminator length when handing the buffer back. This is the inverse of a calculation added to `rb_str_tmp_frozen_no_embed_acquire` in 91619f0230c0e5a95c796c1bd4f784c151e15614. * `heap_str_make_shared`: since bdf3032e3542b318c6f52dbe20d1c97cca3d7067 the root created by `rb_str_tmp_frozen_acquire` does not copy the encoding of the string it takes the buffer from, so the two can have different terminator lengths and the root's capacity has to be adjusted as well, otherwise the root frees fewer bytes than were allocated once its only child stops sharing the buffer. termlen is passed in because the caller assigns the encoding of the root after this function returns. * `rb_str_tmp_frozen_no_embed_acquire`: unset `STR_BORROWED` on the root. `STR_SET_SHARED` sets `STR_BORROWED` on roots with `klass == 0` to mark the ones that may have other children, but this root only ever has orig as its child, so unset it like `heap_str_make_shared` does. Any string that starts sharing the root later will set the flag again. Without this `rb_str_tmp_frozen_release` never gives the buffer back, so strings written by `io_fwrite` stay shared and are copied on their next modification. The release and make_shared adjustments go together as they were previously canceling each other out. --- ext/-test-/string/capacity.c | 9 +++++++++ ext/-test-/string/cstr.c | 1 + internal/string.h | 2 +- string.c | 18 +++++++++++++----- test/-ext-/string/test_capacity.rb | 12 ++++++++++++ 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/ext/-test-/string/capacity.c b/ext/-test-/string/capacity.c index 51f1713d21a3da..61c4a92ab6bed2 100644 --- a/ext/-test-/string/capacity.c +++ b/ext/-test-/string/capacity.c @@ -25,10 +25,19 @@ bug_str_tmp_frozen_acquire_release(VALUE klass, VALUE str) return str; } +static VALUE +bug_str_tmp_frozen_no_embed_acquire_release(VALUE klass, VALUE str) +{ + VALUE tmp = rb_str_tmp_frozen_no_embed_acquire(str); + rb_str_tmp_frozen_release(str, tmp); + return str; +} + void Init_string_capacity(VALUE klass) { rb_define_singleton_method(klass, "capacity", bug_str_capacity, 1); rb_define_singleton_method(klass, "rb_str_new_shared", bug_str_new_shared, 1); rb_define_singleton_method(klass, "tmp_frozen_acquire_release", bug_str_tmp_frozen_acquire_release, 1); + rb_define_singleton_method(klass, "tmp_frozen_no_embed_acquire_release", bug_str_tmp_frozen_no_embed_acquire_release, 1); } diff --git a/ext/-test-/string/cstr.c b/ext/-test-/string/cstr.c index 931220b46bdc40..8de426cd3c740c 100644 --- a/ext/-test-/string/cstr.c +++ b/ext/-test-/string/cstr.c @@ -108,6 +108,7 @@ bug_str_s_cstr_noembed(VALUE self, VALUE str) long capacity = RSTRING_LEN(str) + TERM_LEN(str); char *buf = ALLOC_N(char, capacity); Check_Type(str, T_STRING); + rb_enc_copy(str2, str); FL_SET((str2), STR_NOEMBED); memcpy(buf, RSTRING_PTR(str), capacity); RBASIC(str2)->flags &= ~(STR_SHARED | FL_USER5 | FL_USER6); diff --git a/internal/string.h b/internal/string.h index d407fa9c297017..f350f8d458e26f 100644 --- a/internal/string.h +++ b/internal/string.h @@ -88,7 +88,6 @@ int rb_ascii8bit_appendable_encoding_index(rb_encoding *enc, unsigned int code); VALUE rb_str_include(VALUE str, VALUE arg); VALUE rb_str_byte_substr(VALUE str, VALUE beg, VALUE len); VALUE rb_str_substr_two_fixnums(VALUE str, VALUE beg, VALUE len, int empty); -VALUE rb_str_tmp_frozen_no_embed_acquire(VALUE str); void rb_str_make_embedded(VALUE); VALUE rb_str_upto_each(VALUE, VALUE, int, int (*each)(VALUE, VALUE), VALUE); size_t rb_str_size_as_embedded(VALUE); @@ -115,6 +114,7 @@ RUBY_SYMBOL_EXPORT_BEGIN /* string.c (export) */ VALUE rb_str_tmp_frozen_acquire(VALUE str); void rb_str_tmp_frozen_release(VALUE str, VALUE tmp); +VALUE rb_str_tmp_frozen_no_embed_acquire(VALUE str); VALUE rb_setup_fake_str(struct RString *fake_str, const char *name, long len, rb_encoding *enc); RUBY_SYMBOL_EXPORT_END diff --git a/string.c b/string.c index 391764a14cd063..2985f692f3f82d 100644 --- a/string.c +++ b/string.c @@ -1608,6 +1608,9 @@ rb_str_tmp_frozen_no_embed_acquire(VALUE orig) RBASIC(str)->flags |= RBASIC(orig)->flags & STR_NOFREE; RBASIC(orig)->flags &= ~STR_NOFREE; STR_SET_SHARED(orig, str); + /* str was just allocated here, so orig is its only child and it is + * safe for rb_str_tmp_frozen_release to give the buffer back. */ + FL_UNSET_RAW(str, STR_BORROWED); if (RB_OBJ_SHAREABLE_P(orig)) { RB_OBJ_SET_SHAREABLE(str); RUBY_ASSERT((rb_gc_verify_shareable(str), 1)); @@ -1639,7 +1642,7 @@ rb_str_tmp_frozen_release(VALUE orig, VALUE tmp) /* Unshare orig since the root (tmp) only has this one child. */ FL_UNSET_RAW(orig, STR_SHARED); - RSTRING(orig)->as.heap.aux.capa = RSTRING(tmp)->as.heap.aux.capa; + RSTRING(orig)->as.heap.aux.capa = RSTRING(tmp)->as.heap.aux.capa + TERM_LEN(tmp) - TERM_LEN(orig); RBASIC(orig)->flags |= RBASIC(tmp)->flags & STR_NOFREE; RUBY_ASSERT(OBJ_FROZEN_RAW(tmp)); @@ -1656,8 +1659,13 @@ str_new_frozen(VALUE klass, VALUE orig) return str_new_frozen_buffer(klass, orig, TRUE); } +/* Transfers ownership of orig's buffer to a new shared root string. + * termlen is the terminator length of the returned string, which may differ + * from orig's terminator length when the caller does not copy the encoding. + * The capacity is stored without the terminator, so it must be adjusted for + * the difference to keep the buffer size (capa + termlen) unchanged. */ static VALUE -heap_str_make_shared(VALUE klass, VALUE orig) +heap_str_make_shared(VALUE klass, VALUE orig, int termlen) { RUBY_ASSERT(!STR_EMBED_P(orig)); RUBY_ASSERT(!STR_SHARED_P(orig)); @@ -1666,7 +1674,7 @@ heap_str_make_shared(VALUE klass, VALUE orig) VALUE str = str_alloc_heap(klass); STR_SET_LEN(str, RSTRING_LEN(orig)); RSTRING(str)->as.heap.ptr = RSTRING_PTR(orig); - RSTRING(str)->as.heap.aux.capa = RSTRING(orig)->as.heap.aux.capa; + RSTRING(str)->as.heap.aux.capa = RSTRING(orig)->as.heap.aux.capa + TERM_LEN(orig) - termlen; RBASIC(str)->flags |= RBASIC(orig)->flags & STR_NOFREE; RBASIC(orig)->flags &= ~STR_NOFREE; STR_SET_SHARED(orig, str); @@ -1725,7 +1733,7 @@ str_new_frozen_buffer(VALUE klass, VALUE orig, int copy_encoding) str = str_new(klass, RSTRING_PTR(orig), RSTRING_LEN(orig)); } else { - str = heap_str_make_shared(klass, orig); + str = heap_str_make_shared(klass, orig, termlen); } } } @@ -5862,7 +5870,7 @@ rb_str_drop_bytes(VALUE str, long len) } else { if (!STR_SHARED_P(str)) { - VALUE shared = heap_str_make_shared(rb_obj_class(str), str); + VALUE shared = heap_str_make_shared(rb_obj_class(str), str, TERM_LEN(str)); rb_enc_cr_str_exact_copy(shared, str); OBJ_FREEZE(shared); } diff --git a/test/-ext-/string/test_capacity.rb b/test/-ext-/string/test_capacity.rb index dc02490aefa228..4b09d0cd734577 100644 --- a/test/-ext-/string/test_capacity.rb +++ b/test/-ext-/string/test_capacity.rb @@ -75,6 +75,18 @@ def test_frozen_root_capacity_with_multibyte_terminator assert_equal(capacity, capa(s)) end + def test_frozen_root_no_embed_capacity_with_multibyte_terminator + # This approximates heap strings returned by C extensions: non-embedded, + # not shared, and with an exactly-sized external buffer. + s = Bug::String.cstr_noembed(multibyte_terminator_string) + capacity = capa(s) + assert_operator(capacity, :>=, s.bytesize) + + Bug::String.tmp_frozen_no_embed_acquire_release(s) + + assert_equal(capacity, capa(s)) + end + private def capa(str) From 708492952c2a6cf474a6b7038e4091f9f08b9c94 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 27 Sep 2026 05:29:21 +0900 Subject: [PATCH 02/17] [ruby/test-unit-ruby-core] Pass a message to Timeout.timeout only when given Timeout.timeout in Ruby 2.3 takes at most two arguments, so EnvUtil.timeout always raised ArgumentError there. EnvUtil.terminate reaches it through Debugger#dump on Windows when gdb is installed, which broke test_terminate_reaps_exited_child on windows-latest 2.3. https://github.com/ruby/test-unit-ruby-core/commit/42d15562b2 Co-Authored-By: Claude Opus 5.5 --- tool/lib/envutil.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tool/lib/envutil.rb b/tool/lib/envutil.rb index 5a03a6f4137854..fedcf475279964 100644 --- a/tool/lib/envutil.rb +++ b/tool/lib/envutil.rb @@ -93,7 +93,8 @@ def apply_timeout_scale(t) def timeout(sec, klass = nil, message = nil, &blk) return yield(sec) if sec == nil or sec.zero? sec = apply_timeout_scale(sec) - Timeout.timeout(sec, klass, message, &blk) + # Timeout.timeout in Ruby 2.3 does not take a message + Timeout.timeout(sec, klass, *message, &blk) end module_function :timeout From 145305b0f64e718e1e7d03d3a8597e32183646c6 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:26:30 +0900 Subject: [PATCH 03/17] Remove the disabled Windows branch in rb_str_encode_ospath It has been `#if 0` since 04e95f8985 disabled it to avoid infinite recursion at loading transcoder. Co-Authored-By: Claude Opus 5.5 --- file.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/file.c b/file.c index 091f8bca6d2974..51e68dcade623f 100644 --- a/file.c +++ b/file.c @@ -289,11 +289,6 @@ rb_str_encode_ospath(VALUE path) { #if USE_OSPATH int encidx = ENCODING_GET(path); -#if 0 && defined _WIN32 - if (encidx == ENCINDEX_ASCII_8BIT) { - encidx = rb_filesystem_encindex(); - } -#endif if (encidx != ENCINDEX_ASCII_8BIT && encidx != ENCINDEX_UTF_8) { rb_encoding *enc = rb_enc_from_index(encidx); rb_encoding *utf8 = rb_utf8_encoding(); From 224bb40844b1f5dfe6b698ee699bb4ee60683fec Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:27:08 +0900 Subject: [PATCH 04/17] Remove Windows redefinitions that duplicate win32.h include/ruby/win32.h already maps stat, lstat, access, truncate, utimensat, unlink, rename and getenv to the same rb_w32_* functions when compiling the core with RUBY_EXPORT. Co-Authored-By: Claude Opus 5.5 --- file.c | 16 +--------------- hash.c | 2 -- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/file.c b/file.c index 51e68dcade623f..833c63e965db79 100644 --- a/file.c +++ b/file.c @@ -95,29 +95,16 @@ int flock(int, int); /* define system APIs */ #ifdef _WIN32 # include "win32/file.h" -# define STAT(p, s) rb_w32_ustati128((p), (s)) -# undef lstat -# define lstat(p, s) rb_w32_ulstati128((p), (s)) -# undef access -# define access(p, m) rb_w32_uaccess((p), (m)) -# undef truncate -# define truncate(p, n) rb_w32_utruncate((p), (n)) # undef chmod # define chmod(p, m) rb_w32_uchmod((p), (m)) # undef chown # define chown(p, o, g) rb_w32_uchown((p), (o), (g)) # undef lchown # define lchown(p, o, g) rb_w32_ulchown((p), (o), (g)) -# undef utimensat -# define utimensat(s, p, t, f) rb_w32_uutimensat((s), (p), (t), (f)) # undef link # define link(f, t) rb_w32_ulink((f), (t)) -# undef unlink -# define unlink(p) rb_w32_uunlink(p) # undef readlink # define readlink(f, t, l) rb_w32_ureadlink((f), (t), (l)) -# undef rename -# define rename(f, t) rb_w32_urename((f), (t)) # undef symlink # define symlink(s, l) rb_w32_usymlink((s), (l)) @@ -126,9 +113,8 @@ int flock(int, int); absolute paths does not work for drive letters. */ # undef HAVE_REALPATH # endif -#else -# define STAT(p, s) stat((p), (s)) #endif /* _WIN32 */ +#define STAT(p, s) stat((p), (s)) #ifdef HAVE_STRUCT_STATX_STX_BTIME # define ST_(name) stx_ ## name diff --git a/hash.c b/hash.c index cec50e0ad5250f..a980d05baa2fd0 100644 --- a/hash.c +++ b/hash.c @@ -5457,8 +5457,6 @@ static char **origenviron; static char **my_environ; #undef environ #define environ my_environ -#undef getenv -#define getenv(n) rb_w32_ugetenv(n) #elif defined(__APPLE__) #undef environ #define environ (*_NSGetEnviron()) From cb005af5ea61c8ea215877c6b951fe0c72fe2fdb Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:27:34 +0900 Subject: [PATCH 05/17] Remove the redundant _WIN32 check in rb_group_member Neither mswin nor mingw defines HAVE_GETGROUPS, so the remaining condition already covers Windows. Co-Authored-By: Claude Opus 5.5 --- file.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/file.c b/file.c index 833c63e965db79..600cffcea0ae55 100644 --- a/file.c +++ b/file.c @@ -1710,7 +1710,7 @@ rb_file_lstat(VALUE obj) static int rb_group_member(GETGROUPS_T gid) { -#if defined(_WIN32) || !defined(HAVE_GETGROUPS) +#if !defined(HAVE_GETGROUPS) return FALSE; #else int rv = FALSE; @@ -1735,7 +1735,7 @@ rb_group_member(GETGROUPS_T gid) ALLOCV_END(v); return rv; -#endif /* defined(_WIN32) || !defined(HAVE_GETGROUPS) */ +#endif /* !defined(HAVE_GETGROUPS) */ } #ifndef S_IXUGO From dbbc81fefc6b194cb82f56b8d9dede04ed025867 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:28:21 +0900 Subject: [PATCH 06/17] Stop including missing/file.h in dln.c and util.c Nothing in them has used or the L_* and *_OK macros since 9afa9ba967 split dln_find.c out and f9e9eee677 removed ruby_add_suffix. Co-Authored-By: Claude Opus 5.5 --- dln.c | 3 --- util.c | 4 ---- 2 files changed, 7 deletions(-) diff --git a/dln.c b/dln.c index 35f097218a7d58..aa7c1ceb330e02 100644 --- a/dln.c +++ b/dln.c @@ -56,9 +56,6 @@ void *xrealloc(); #define free(x) xfree(x) #include -#if defined(_WIN32) -#include "missing/file.h" -#endif #include #include diff --git a/util.c b/util.c index 3315eb575e1a28..4c52c6adf93baf 100644 --- a/util.c +++ b/util.c @@ -25,10 +25,6 @@ #include #include -#ifdef _WIN32 -# include "missing/file.h" -#endif - #include "internal.h" #include "internal/sanitizers.h" #include "internal/imemo.h" From 8512e07d0bb0c7d08ff0237e1f0283e1e07504b4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 15 Sep 2026 08:19:41 +0900 Subject: [PATCH 07/17] win32: Keep all sockets in the sets while polling select The polling loop saved and restored the sets through fixed fd_set buffers, which hold only FD_SETSIZE (64) sockets. From the second poll on, sockets past that were no longer watched, so IO.select could miss them until the timeout. Co-Authored-By: Claude Opus 5 --- test/ruby/test_io.rb | 15 +++++++++++++++ win32/win32.c | 43 +++++++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/test/ruby/test_io.rb b/test/ruby/test_io.rb index eaa2d02887d3d3..29d470cbf42767 100644 --- a/test/ruby/test_io.rb +++ b/test/ruby/test_io.rb @@ -4491,6 +4491,21 @@ def test_select_exceptfds end end if Socket.const_defined?(:MSG_OOB) + def test_select_many_sockets + pairs = [] + TCPServer.open('localhost', 0) do |svr| + # more than FD_SETSIZE on Windows + 70.times {pairs << [TCPSocket.new('localhost', svr.addr[1]), svr.accept]} + end + readers = pairs.map(&:last) + # best effort to write after select has polled once, which Ruby cannot observe + th = Thread.new {sleep 0.2; pairs.last.first.write("x")} + assert_equal([[readers.last], [], []], IO.select(readers, nil, pairs.map(&:first), 10)) + ensure + th&.join + pairs.flatten.each(&:close) + end + def test_select_timeout assert_equal(nil, IO.select(nil,nil,nil,0)) assert_equal(nil, IO.select(nil,nil,nil,0.0)) diff --git a/win32/win32.c b/win32/win32.c index 9a0ae6fda17d42..08f9546ca71c18 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -3015,6 +3015,22 @@ copy_fd(fd_set *dst, fd_set *src) return dst->fd_count; } +/* License: Ruby's */ +static void +save_fd(SOCKET *dst, const fd_set *src) +{ + if (src) memcpy(dst, src->fd_array, src->fd_count * sizeof(SOCKET)); +} + +/* License: Ruby's */ +static void +restore_fd(fd_set *dst, const SOCKET *src, UINT count) +{ + if (!dst) return; + memcpy(dst->fd_array, src, count * sizeof(SOCKET)); + dst->fd_count = count; +} + /* License: Ruby's */ static int is_not_socket(SOCKET sock) @@ -3250,6 +3266,15 @@ rb_w32_select_with_thread(int nfds, fd_set *rd, fd_set *wr, fd_set *ex, struct timeval rest; const struct timeval wait = {0, 10 * 1000}; // 10ms struct timeval zero = {0, 0}; // 0ms + // the sets may hold more than FD_SETSIZE sockets + UINT nrd = rd ? rd->fd_count : 0; + UINT nwr = wr ? wr->fd_count : 0; + UINT nex = ex ? ex->fd_count : 0; + SOCKET *orig = ALLOC_N(SOCKET, nrd + nwr + nex); + + save_fd(orig, rd); + save_fd(orig + nrd, wr); + save_fd(orig + nrd + nwr, ex); for (;;) { if (th && rb_w32_check_interrupt(th) != WAIT_TIMEOUT) { r = -1; @@ -3274,22 +3299,11 @@ rb_w32_select_with_thread(int nfds, fd_set *rd, fd_set *wr, fd_set *ex, else { const struct timeval *dowait = &wait; - fd_set orig_rd; - fd_set orig_wr; - fd_set orig_ex; - - FD_ZERO(&orig_rd); - FD_ZERO(&orig_wr); - FD_ZERO(&orig_ex); - - if (rd) copy_fd(&orig_rd, rd); - if (wr) copy_fd(&orig_wr, wr); - if (ex) copy_fd(&orig_ex, ex); r = do_select(nfds, rd, wr, ex, &zero); // polling if (r != 0) break; // signaled or error - if (rd) copy_fd(rd, &orig_rd); - if (wr) copy_fd(wr, &orig_wr); - if (ex) copy_fd(ex, &orig_ex); + restore_fd(rd, orig, nrd); + restore_fd(wr, orig + nrd, nwr); + restore_fd(ex, orig + nrd + nwr, nex); if (timeout) { struct timeval now; @@ -3301,6 +3315,7 @@ rb_w32_select_with_thread(int nfds, fd_set *rd, fd_set *wr, fd_set *ex, Sleep(dowait->tv_sec * 1000 + (dowait->tv_usec + 999) / 1000); } } + ruby_xfree_sized(orig, sizeof(SOCKET) * (nrd + nwr + nex)); } rb_fd_term(&except); From 4e9e62898cd1c5c46fe103bd9669d743fd6ec31b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 15 Sep 2026 08:19:51 +0900 Subject: [PATCH 08/17] win32: Do not drop non-socket handles from select results When FD_SETSIZE (64) or more sockets were ready, copy_fd stopped there and a ready pipe or other non-socket handle was left out of the result. Co-Authored-By: Claude Opus 5 --- test/ruby/test_io.rb | 4 ++++ win32/win32.c | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/ruby/test_io.rb b/test/ruby/test_io.rb index 29d470cbf42767..f76567383f016d 100644 --- a/test/ruby/test_io.rb +++ b/test/ruby/test_io.rb @@ -4501,6 +4501,10 @@ def test_select_many_sockets # best effort to write after select has polled once, which Ruby cannot observe th = Thread.new {sleep 0.2; pairs.last.first.write("x")} assert_equal([[readers.last], [], []], IO.select(readers, nil, pairs.map(&:first), 10)) + IO.pipe do |r, w| + writers = [*pairs.map(&:first), w] + assert_equal([[], writers, []], IO.select(nil, writers, nil, 10)) + end ensure th&.join pairs.flatten.each(&:close) diff --git a/win32/win32.c b/win32/win32.c index 08f9546ca71c18..4738bbb7acdb72 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -3007,7 +3007,7 @@ copy_fd(fd_set *dst, fd_set *src) if (dst->fd_array[d] == fd) break; } - if (d == dst->fd_count && d < FD_SETSIZE) { + if (d == dst->fd_count) { dst->fd_array[dst->fd_count++] = fd; } } @@ -3290,6 +3290,7 @@ rb_w32_select_with_thread(int nfds, fd_set *rd, fd_set *wr, fd_set *ex, if (else_rd.fdset->fd_count || else_wr.fdset->fd_count) { r = do_select(nfds, rd, wr, ex, &zero); // polling if (r < 0) break; // XXX: should I ignore error and return signaled handles? + // else_{rd,wr} came out of {rd,wr}, which have room for them r += copy_fd(rd, else_rd.fdset); r += copy_fd(wr, else_wr.fdset); if (ex) From b992b96e56a49ed414d5f808e4a8ec9fcaf366a5 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 15 Sep 2026 08:19:53 +0900 Subject: [PATCH 09/17] win32: Leave the select sets empty on timeout The polling loop restored every socket into the sets before checking the deadline, so a timed-out select returned them all as ready. When IO.select forced a zero timeout for buffered data, sockets with nothing to read were reported readable. Co-Authored-By: Claude Opus 5 --- test/ruby/test_io.rb | 13 +++++++++++++ win32/win32.c | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/test/ruby/test_io.rb b/test/ruby/test_io.rb index f76567383f016d..97997e21194e0f 100644 --- a/test/ruby/test_io.rb +++ b/test/ruby/test_io.rb @@ -4510,6 +4510,19 @@ def test_select_many_sockets pairs.flatten.each(&:close) end + def test_select_buffered_socket + pairs = [] + TCPServer.open('localhost', 0) do |svr| + 2.times {pairs << [TCPSocket.new('localhost', svr.addr[1]), svr.accept]} + end + readers = pairs.map(&:last) + pairs.first.first.write("xy") + assert_equal("x", readers.first.getc) + assert_equal([[readers.first], [], []], IO.select(readers, nil, nil, 1)) + ensure + pairs.flatten.each(&:close) + end + def test_select_timeout assert_equal(nil, IO.select(nil,nil,nil,0)) assert_equal(nil, IO.select(nil,nil,nil,0.0)) diff --git a/win32/win32.c b/win32/win32.c index 4738bbb7acdb72..39186d6da6cc46 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -3302,17 +3302,17 @@ rb_w32_select_with_thread(int nfds, fd_set *rd, fd_set *wr, fd_set *ex, r = do_select(nfds, rd, wr, ex, &zero); // polling if (r != 0) break; // signaled or error - restore_fd(rd, orig, nrd); - restore_fd(wr, orig + nrd, nwr); - restore_fd(ex, orig + nrd + nwr, nex); if (timeout) { struct timeval now; gettimeofday(&now, NULL); rest = limit; - if (!rb_w32_time_subtract(&rest, &now)) break; + if (!rb_w32_time_subtract(&rest, &now)) break; // leave the sets empty if (compare(&rest, &wait) < 0) dowait = &rest; } + restore_fd(rd, orig, nrd); + restore_fd(wr, orig + nrd, nwr); + restore_fd(ex, orig + nrd + nwr, nex); Sleep(dowait->tv_sec * 1000 + (dowait->tv_usec + 999) / 1000); } } From 3e7be388180b57ac97d9e2bf637af17232e7897a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 09:43:59 +0900 Subject: [PATCH 10/17] Do not skip path separators after multibyte characters on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUBYLIB and -I are UTF-8 on Windows, but ruby_push_include walked them with CharNext in the ANSI code page. With a DBCS code page such as 932, the last byte of a UTF-8 character was taken as a lead byte and swallowed the following `;`, so `C:/あ;C:/b` became a single load path. Co-Authored-By: Claude Opus 5.5 --- ruby.c | 10 +++++++++- test/ruby/test_rubyoptions.rb | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ruby.c b/ruby.c index 9fb594a139a38a..58b26320c7b910 100644 --- a/ruby.c +++ b/ruby.c @@ -454,6 +454,13 @@ ruby_push_include(const char *path, VALUE (*filter)(VALUE)) # define is_path_sep(c) ((c) == sep || (c) == ';') #else # define is_path_sep(c) ((c) == sep) +#endif +#ifdef _WIN32 + /* RUBYLIB and -I are UTF-8 while CharNext() follows the ANSI code + * page, and neither has the separator in a multibyte character. */ +# define next_char(s) ((s) + 1) +#else +# define next_char(s) CharNext(s) #endif if (path == 0) return; @@ -463,9 +470,10 @@ ruby_push_include(const char *path, VALUE (*filter)(VALUE)) while (is_path_sep(*p)) p++; if (!*p) break; - for (s = p; *s && !is_path_sep(*s); s = CharNext(s)); + for (s = p; *s && !is_path_sep(*s); s = next_char(s)); len = s - p; #undef is_path_sep +#undef next_char #ifdef __CYGWIN__ if (*s) { diff --git a/test/ruby/test_rubyoptions.rb b/test/ruby/test_rubyoptions.rb index b00d852823e631..2df2cf6417c914 100644 --- a/test/ruby/test_rubyoptions.rb +++ b/test/ruby/test_rubyoptions.rb @@ -1332,6 +1332,13 @@ def test_rubylib_invalid_encoding assert_ruby_status([env, "-e;"]) end + def test_path_separator_after_multibyte_char + path = ["./\u{3042}", "./b"].join(File::PATH_SEPARATOR) + code = "p $:.include?('./b')" + assert_in_out_err([{"RUBYLIB"=>path}, "-e", code], "", %w[true], []) + assert_in_out_err(["-I", path, "-e", code], "", %w[true], []) + end + def test_null_script omit "#{IO::NULL} is not a character device" unless File.chardev?(IO::NULL) assert_in_out_err([IO::NULL], success: true) From 10eee53bd5aeaa758d32d2a512f2b595ff338484 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 09:44:00 +0900 Subject: [PATCH 11/17] Do not skip path delimiters after multibyte characters in dln_find on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dln_find.c object built on its own receives UTF-8 command names from process.c, but scanned them with CharNext in the ANSI code page. With a DBCS code page, a backslash after a multibyte character was missed, and `exec("あ\\foo", "x")` ran `あ\foo` found in PATH instead of the one under the current directory. The copy that win32.c includes keeps using its code page. Co-Authored-By: Claude Opus 5.5 --- dln_find.c | 7 ++++--- test/ruby/test_process.rb | 15 +++++++++++++++ win32/win32.c | 3 +-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/dln_find.c b/dln_find.c index 2d2bd1bd296b16..aeb9390caebef1 100644 --- a/dln_find.c +++ b/dln_find.c @@ -124,8 +124,9 @@ dln_find_1(const char *fname, const char *path, char *fbuf, size_t size, return NULL; } #ifdef DOSISH -# ifndef CharNext -# define CharNext(p) ((p)+1) +# ifndef DLN_CHAR_NEXT +/* fname is UTF-8 unless win32.c includes this file with its code page */ +# define DLN_CHAR_NEXT(p) ((p)+1) # endif # ifdef DOSISH_DRIVE_LETTER if ((unsigned char)((p[0] | 0x20) - 'a') < 26u && p[1] == ':') { @@ -151,7 +152,7 @@ dln_find_1(const char *fname, const char *path, char *fbuf, size_t size, p++; break; default: - p = CharNext(p); + p = DLN_CHAR_NEXT(p); } } if (ext) { diff --git a/test/ruby/test_process.rb b/test/ruby/test_process.rb index 1fc8f31240baec..b0815abc1c4cc9 100644 --- a/test/ruby/test_process.rb +++ b/test/ruby/test_process.rb @@ -1166,6 +1166,21 @@ def test_exec_noshell } end + def test_exec_relative_path_after_multibyte_dir + return unless windows? + with_tmpchdir {|d| + dir = "\u{3042}" + Dir.mkdir(dir) + File.write("#{dir}/foo.cmd", "@echo cwd\n") + Dir.mkdir("path") + Dir.mkdir("path/#{dir}") + File.write("path/#{dir}/foo.cmd", "@echo path\n") + env = {"PATH"=>"#{d}/path;#{ENV["PATH"]}"} + r = IO.popen([env, RUBY, "-e", "exec(*ARGV)", "#{dir}\\foo", "x"], &:read) + assert_equal("cwd", r.chomp) + } + end + def test_system_wordsplit with_tmpchdir {|d| File.write("script", <<-'End') diff --git a/win32/win32.c b/win32/win32.c index 39186d6da6cc46..578d2a56a98f6b 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -81,8 +81,7 @@ static char *w32_getenv(const char *name, UINT cp); #define DLN_FIND_EXTRA_ARG ,cp #define rb_w32_stati128(path, st) w32_stati128(path, st, cp, FALSE) #define getenv(name) w32_getenv(name, cp) /* Necessarily For dln.c */ -#undef CharNext -#define CharNext(p) CharNextExA(cp, (p), 0) +#define DLN_CHAR_NEXT(p) CharNextExA(cp, (p), 0) #define dln_find_exe_r rb_w32_udln_find_exe_r #define dln_find_file_r rb_w32_udln_find_file_r #include "dln.h" From cd3657cf06bb3a32756deea35ea2b6cdfd19eb41 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 27 Sep 2026 05:23:48 +0900 Subject: [PATCH 12/17] Remove CharNext from eval_intern.h ruby_push_include was its last user. A path separator never appears inside a multibyte character, so walk RUBYLIB and -I by bytes everywhere. This also fixes the same skipped separator on Cygwin, where CharNext came from windows.h. Co-Authored-By: Claude Opus 5.5 --- eval_intern.h | 17 ----------------- ruby.c | 11 ++--------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/eval_intern.h b/eval_intern.h index 25ea8a9ef1db24..ca4e4b50615914 100644 --- a/eval_intern.h +++ b/eval_intern.h @@ -353,21 +353,4 @@ VALUE rb_ec_backtrace_object(const rb_execution_context_t *ec); VALUE rb_ec_backtrace_str_ary(const rb_execution_context_t *ec, long lev, long n); VALUE rb_ec_backtrace_location_ary(const rb_execution_context_t *ec, long lev, long n, bool skip_internal); -#ifndef CharNext /* defined as CharNext[AW] on Windows. */ -# ifdef HAVE_MBLEN -# define CharNext(p) rb_char_next(p) -static inline char * -rb_char_next(const char *p) -{ - if (p) { - int len = mblen(p, RUBY_MBCHAR_MAXSIZE); - p += len > 0 ? len : 1; - } - return (char *)p; -} -# else -# define CharNext(p) ((p) + 1) -# endif -#endif - #endif /* RUBY_EVAL_INTERN_H */ diff --git a/ruby.c b/ruby.c index 58b26320c7b910..e1164da05c3b13 100644 --- a/ruby.c +++ b/ruby.c @@ -454,13 +454,6 @@ ruby_push_include(const char *path, VALUE (*filter)(VALUE)) # define is_path_sep(c) ((c) == sep || (c) == ';') #else # define is_path_sep(c) ((c) == sep) -#endif -#ifdef _WIN32 - /* RUBYLIB and -I are UTF-8 while CharNext() follows the ANSI code - * page, and neither has the separator in a multibyte character. */ -# define next_char(s) ((s) + 1) -#else -# define next_char(s) CharNext(s) #endif if (path == 0) return; @@ -470,10 +463,10 @@ ruby_push_include(const char *path, VALUE (*filter)(VALUE)) while (is_path_sep(*p)) p++; if (!*p) break; - for (s = p; *s && !is_path_sep(*s); s = next_char(s)); + /* separators never appear inside a multibyte character */ + for (s = p; *s && !is_path_sep(*s); s++); len = s - p; #undef is_path_sep -#undef next_char #ifdef __CYGWIN__ if (*s) { From 7cdbc846143a2feb2732bf06fb3fd17997165987 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 27 Sep 2026 07:08:34 +0900 Subject: [PATCH 13/17] Return the byte count from IO#write to a Windows console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rb_w32_write_console` returns the number of UTF-16 code units it wrote, so writing "あ" to a console returned 1 instead of 3 as it does for files and NUL. Use the result only to tell success, as the other callers do. Co-Authored-By: Claude Opus 5.5 --- io.c | 3 +-- test/ruby/test_io_m17n.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/io.c b/io.c index 6617b2cc0d6d6f..90a2fcfa7824f5 100644 --- a/io.c +++ b/io.c @@ -2114,8 +2114,7 @@ io_fwrite(VALUE str, rb_io_t *fptr, int nosync) #ifdef _WIN32 if (fptr->mode & FMODE_TTY) { - long len = rb_w32_write_console(str, fptr->fd); - if (len > 0) return len; + if (rb_w32_write_console(str, fptr->fd) > 0) return RSTRING_LEN(str); } #endif diff --git a/test/ruby/test_io_m17n.rb b/test/ruby/test_io_m17n.rb index 83b94e1b3e7155..d957d852e67135 100644 --- a/test/ruby/test_io_m17n.rb +++ b/test/ruby/test_io_m17n.rb @@ -2656,6 +2656,19 @@ def test_default_stdout_stderr_mode end end if /mswin|mingw/ =~ RUBY_PLATFORM + def test_write_to_console + begin + con = File.open("CONOUT$", "r+") + rescue SystemCallError + omit "console is not available" + end + # non-ASCII, but leaves nothing visible on the console + str = "\u00a0\b" + assert_equal(str.bytesize, con.write(str)) + ensure + con&.close + end if /mswin|mingw/ =~ RUBY_PLATFORM + def test_cr_decorator_on_stdout with_pipe do |in_r, in_w| with_pipe do |out_r, out_w| From 2151a047b282432d9a0e06929f62a63604fa2b3e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 15:23:15 +0900 Subject: [PATCH 14/17] Ignore PROCESSOR_ARCHITEW6432 in the ENV tests on WOW64 On 64-bit Windows, WOW64 sets PROCESSOR_ARCHITEW6432 when a 32-bit process starts, like PROCESSOR_ARCHITECTURE under the x64 emulation on ARM64, so test_execopts_env and test_execopts_unsetenv_others fail on i386-mswin32. Co-Authored-By: Claude Opus 5.5 --- test/ruby/test_process.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ruby/test_process.rb b/test/ruby/test_process.rb index b0815abc1c4cc9..29f1465b2f5a9e 100644 --- a/test/ruby/test_process.rb +++ b/test/ruby/test_process.rb @@ -292,7 +292,7 @@ def test_overwrite_ENV end case RbConfig::CONFIG['target_os'] when /mswin|mingw/ - MANDATORY_ENVS.concat(%w[HOME USER TMPDIR PROCESSOR_ARCHITECTURE]) + MANDATORY_ENVS.concat(%w[HOME USER TMPDIR PROCESSOR_ARCHITECTURE PROCESSOR_ARCHITEW6432]) when /darwin/ MANDATORY_ENVS.concat(%w[TMPDIR], ENV.keys.grep(/\A__CF_/)) # IO.popen([ENV.keys.to_h {|e| [e, nil]}, From 51f85d542e3a57e45900b17842618b5fc5954584 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 16:08:35 +0900 Subject: [PATCH 15/17] CI: Do not mistake the x86 ruby DLL for the VC runtime On i386-mswin32 the ruby DLL has no arch prefix and is named vcruntime140-ruby410.dll, so the vcruntime140*.dll check rejected every x86 binary package. Match only the VC runtime DLL names. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 2bf8617c509d05..15892b72f78a92 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -215,7 +215,7 @@ jobs: if ((Get-ChildItem $dest).Count -ne 1) { throw "zip must contain a single root directory" } $root = (Get-ChildItem $dest)[0].FullName if (!(Test-Path "$root\bin\ruby.exe")) { throw "bin\ruby.exe not found" } - if (Get-ChildItem "$root\bin" -Filter "vcruntime140*.dll") { throw "the VC runtime must not be bundled" } + if (Get-ChildItem "$root\bin" | Where-Object Name -match '^vcruntime140(_\w+)?\.dll$') { throw "the VC runtime must not be bundled" } if (!(Get-ChildItem "$root\LICENSES" -ErrorAction SilentlyContinue)) { throw "LICENSES missing" } $env:PATH = "$env:SystemRoot\System32" & "$root\bin\ruby.exe" -v -ropenssl -rfiddle -rpsych -rzlib -e 'abort "configure_args has an absolute path: #{RbConfig::CONFIG[%q(configure_args)]}" if RbConfig::CONFIG[%q(configure_args)] =~ /\b[A-Za-z]:[\/\\]/; puts %q(binary package OK)' From 7505510c235afb10ee85ce676fd4a2ffd3ed78d4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 14:34:07 +0900 Subject: [PATCH 16/17] CI: Build and test x86 mswin on Windows Breakages specific to i386-mswin32, such as the size_t atomic fetch_add and the prism bit.h build failure, were found only by auditing. Run check on x86 as well so that CI catches them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 15892b72f78a92..8e153418e16025 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -33,6 +33,9 @@ jobs: test_task: check - os: 2025-vs2026 test_task: test-bundled-gems + - os: 2025-vs2026 + test_task: check + target: x86 - os: 11-arm test_task: check target: arm64 @@ -48,7 +51,7 @@ jobs: || (github.event.pull_request.user.login == 'dependabot[bot]' && !startsWith(github.head_ref, 'dependabot/vcpkg')) )}} - name: Windows ${{ matrix.os }} (${{ matrix.test_task }}) + name: Windows ${{ matrix.os }}${{ matrix.target == 'x86' && ' x86' || '' }} (${{ matrix.test_task }}) permissions: contents: read @@ -185,7 +188,7 @@ jobs: - name: Set up Launchable uses: ./.github/actions/launchable/setup with: - os: windows-${{ matrix.os }} + os: windows-${{ matrix.os }}${{ matrix.target == 'x86' && '-x86' || '' }} launchable-token: ${{ secrets.LAUNCHABLE_TOKEN }} builddir: build srcdir: src @@ -224,7 +227,7 @@ jobs: - uses: ./.github/actions/slack with: - label: Windows ${{ matrix.os }} / ${{ matrix.test_task || 'check' }} + label: Windows ${{ matrix.os }}${{ matrix.target == 'x86' && ' x86' || '' }} / ${{ matrix.test_task || 'check' }} SLACK_WEBHOOK_URL: ${{ secrets.SIMPLER_ALERTS_URL }} # ruby-lang slack: ruby/simpler-alerts-bot if: ${{ failure() }} From 7fb3844370953f355e3b469109b7f435d6c0d29a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 27 Sep 2026 05:44:19 +0900 Subject: [PATCH 17/17] CI: Use case() for the x86 suffix in the Windows workflow Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 8e153418e16025..6aaae6f8230a79 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -51,7 +51,7 @@ jobs: || (github.event.pull_request.user.login == 'dependabot[bot]' && !startsWith(github.head_ref, 'dependabot/vcpkg')) )}} - name: Windows ${{ matrix.os }}${{ matrix.target == 'x86' && ' x86' || '' }} (${{ matrix.test_task }}) + name: Windows ${{ matrix.os }}${{ case(matrix.target == 'x86', ' x86', '') }} (${{ matrix.test_task }}) permissions: contents: read @@ -188,7 +188,7 @@ jobs: - name: Set up Launchable uses: ./.github/actions/launchable/setup with: - os: windows-${{ matrix.os }}${{ matrix.target == 'x86' && '-x86' || '' }} + os: windows-${{ matrix.os }}${{ case(matrix.target == 'x86', '-x86', '') }} launchable-token: ${{ secrets.LAUNCHABLE_TOKEN }} builddir: build srcdir: src @@ -227,7 +227,7 @@ jobs: - uses: ./.github/actions/slack with: - label: Windows ${{ matrix.os }}${{ matrix.target == 'x86' && ' x86' || '' }} / ${{ matrix.test_task || 'check' }} + label: Windows ${{ matrix.os }}${{ case(matrix.target == 'x86', ' x86', '') }} / ${{ matrix.test_task || 'check' }} SLACK_WEBHOOK_URL: ${{ secrets.SIMPLER_ALERTS_URL }} # ruby-lang slack: ruby/simpler-alerts-bot if: ${{ failure() }}