From 7d84181510159d0f9b2821c246b907152d003118 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 14:04:27 +0900 Subject: [PATCH 01/14] mswin: Update required Windows version to Windows 10 Update the default `NTVER` so that the declarations of APIs introduced in Windows 10 are enabled. `_WIN32_WINNT` has no value for version 1809, the new minimum, so `_WIN32_WINNT_WIN10` is the closest one. Co-Authored-By: Claude Opus 5.5 --- win32/Makefile.sub | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/win32/Makefile.sub b/win32/Makefile.sub index fd8140116514af..5f934ffa2c436a 100644 --- a/win32/Makefile.sub +++ b/win32/Makefile.sub @@ -187,7 +187,7 @@ PLATFORM = mswin32 ! error Runtime library $(RT_VER) is not supported !endif !ifndef NTVER -NTVER = _WIN32_WINNT_WIN8 +NTVER = _WIN32_WINNT_WIN10 !endif !ifdef NTVER ARCHDEFS = -D_WIN32_WINNT=$(NTVER) $(ARCHDEFS) From ccdc278e75c7ddd2762853838a7b82954a3c8721 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 14:04:47 +0900 Subject: [PATCH 02/14] Document Windows 10 version 1809 as the minimum for mswin Windows Server 2016 and Windows 10 Enterprise LTSB 2016 reach end of support by January 2027. Version 1809 is the oldest release still supported afterwards, as Windows Server 2019 and Windows 10 Enterprise LTSC 2019 until January 2029. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 4 ++++ doc/distribution/windows.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 91c6221e1903e5..061223cc3b11b6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -312,6 +312,10 @@ Ruby 4.0 bundled RubyGems and Bundler version 4. see the following links for det * Building Ruby with MSVC now requires Visual Studio 2017 version 15.8 (`_MSC_VER` 1915) or later. +* Ruby built with MSVC now requires Windows 10 version 1809 (build 17763) + or Windows Server 2019 or later. Earlier Windows 10 releases and Windows + Server 2016 are no longer supported. + ## Compatibility issues * A class or module can now be modified only by the Ractor which created it, diff --git a/doc/distribution/windows.md b/doc/distribution/windows.md index 67eb4acb430ebb..d8b6f128fb1b54 100644 --- a/doc/distribution/windows.md +++ b/doc/distribution/windows.md @@ -70,7 +70,7 @@ sh ../../ruby/configure -C --disable-install-doc --with-opt-dir=C:\Users\usernam ### Requirement -1. Windows 10/Windows Server 2016 or later. +1. Windows 10 version 1809 (build 17763)/Windows Server 2019 or later. 2. Visual C++ 14.15 (Visual Studio 2017 version 15.8) or later. From 8094e4e2477fc68577bd36447424a61e75749992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20G=C3=B6rgens?= Date: Sat, 26 Sep 2026 18:11:00 +0800 Subject: [PATCH 03/14] Don't lose consumed bytes when IO#read times out (#19053) * io: preserve data after read timeout * io: make timeout regression tests robust against small send buffers A synchronous write of the whole payload blocks forever once the socket send buffer fills (8 KiB by default on macOS and Windows), hanging the test before any read. Write a small prefix synchronously, deliver the remainder from a writer thread, and drain it with a generous timeout before joining. * io: give timeout regression tests time to consume the full payload A 0.1s deadline lets the timed read drain the whole payload from the writer thread before raising, so the multi-chunk rollback stays exercised even where the send buffer is smaller than the payload. --- io.c | 66 +++++++++++++++++++++++++++++++++--- test/ruby/test_io_timeout.rb | 40 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/io.c b/io.c index 7917ddde7e50b5..d0b5a4be6d27cb 100644 --- a/io.c +++ b/io.c @@ -1035,6 +1035,32 @@ io_ungetbyte(VALUE str, rb_io_t *fptr) MEMMOVE(fptr->rbuf.ptr+fptr->rbuf.off, RSTRING_PTR(str), char, len); } +static void +io_restore_read_buffer(VALUE str, rb_io_t *fptr) +{ + long len = RSTRING_LEN(str); + + if (len > INT_MAX - fptr->rbuf.len) { + rb_raise(rb_eIOError, "read buffer too large"); + } + if (fptr->rbuf.ptr == NULL || fptr->rbuf.capa >= len + fptr->rbuf.len) { + io_ungetbyte(str, fptr); + } + else { + int pending = fptr->rbuf.len; + int capa = (int)len + pending; + char *ptr = ALLOC_N(char, capa); + + MEMMOVE(ptr, RSTRING_PTR(str), char, len); + MEMMOVE(ptr + len, fptr->rbuf.ptr + fptr->rbuf.off, char, pending); + ruby_xfree(fptr->rbuf.ptr); + fptr->rbuf.ptr = ptr; + fptr->rbuf.off = 0; + fptr->rbuf.len = capa; + fptr->rbuf.capa = capa; + } +} + static rb_io_t * flush_before_seek(rb_io_t *fptr, bool discard_rbuf) { @@ -3150,12 +3176,13 @@ read_buffered_data(char *ptr, long len, rb_io_t *fptr) } static long -io_bufread(char *ptr, long len, rb_io_t *fptr) +io_bufread(char *ptr, long len, rb_io_t *fptr, long *read_len) { long offset = 0; long n = len; long c; + *read_len = 0; if (READ_DATA_PENDING(fptr) == 0) { while (n > 0) { again: @@ -3168,6 +3195,7 @@ io_bufread(char *ptr, long len, rb_io_t *fptr) return -1; } offset += c; + *read_len = offset; if ((n -= c) <= 0) break; } return len - n; @@ -3177,6 +3205,7 @@ io_bufread(char *ptr, long len, rb_io_t *fptr) c = read_buffered_data(ptr+offset, n, fptr); if (c > 0) { offset += c; + *read_len = offset; if ((n -= c) <= 0) break; } rb_io_check_closed(fptr); @@ -3191,18 +3220,45 @@ static int io_setstrbuf(VALUE *str, long len); struct bufread_arg { char *str_ptr; + long offset; long len; + long read_len; rb_io_t *fptr; }; static VALUE -bufread_call(VALUE arg) +bufread_body(VALUE arg) { struct bufread_arg *p = (struct bufread_arg *)arg; - p->len = io_bufread(p->str_ptr, p->len, p->fptr); + p->len = io_bufread(p->str_ptr + p->offset, p->len, p->fptr, &p->read_len); return Qundef; } +static VALUE +bufread_timeout(VALUE arg, VALUE error) +{ + struct bufread_arg *p = (struct bufread_arg *)arg; + + if (p->offset + p->read_len > 0) { + VALUE str = rb_str_new(p->str_ptr, p->offset + p->read_len); + io_restore_read_buffer(str, p->fptr); + } + rb_exc_raise(error); + UNREACHABLE_RETURN(Qnil); +} + +static VALUE +bufread_call(VALUE arg) +{ + struct bufread_arg *p = (struct bufread_arg *)arg; + + if (NIL_P(p->fptr->timeout)) { + return bufread_body(arg); + } + return rb_rescue2(bufread_body, arg, bufread_timeout, arg, + rb_eIOTimeoutError, (VALUE)0); +} + static long io_fread(VALUE str, long offset, long size, rb_io_t *fptr) { @@ -3210,8 +3266,10 @@ io_fread(VALUE str, long offset, long size, rb_io_t *fptr) struct bufread_arg arg; io_setstrbuf(&str, offset + size); - arg.str_ptr = RSTRING_PTR(str) + offset; + arg.str_ptr = RSTRING_PTR(str); + arg.offset = offset; arg.len = size; + arg.read_len = 0; arg.fptr = fptr; rb_str_locktmp_ensure(str, bufread_call, (VALUE)&arg); len = arg.len; diff --git a/test/ruby/test_io_timeout.rb b/test/ruby/test_io_timeout.rb index e017395980564b..6ec1d5adf72f7e 100644 --- a/test/ruby/test_io_timeout.rb +++ b/test/ruby/test_io_timeout.rb @@ -1,6 +1,7 @@ # frozen_string_literal: false require 'io/nonblock' +require 'socket' class TestIOTimeout < Test::Unit::TestCase def with_pipe @@ -38,6 +39,45 @@ def test_timeout_read_exception end end + def test_timeout_read_preserves_buffered_data + with_pipe do |i, o| + data = "Hello" * 4_000 + o.write(data.byteslice(0, 1024)) + writer = Thread.new { o.write(data.byteslice(1024, data.bytesize - 1024)) } + i.timeout = 0.1 + + assert_raise(IO::TimeoutError) {i.read} + i.timeout = 1 + assert_equal data, i.read(data.bytesize) + writer.join + end + end + + def test_timeout_sized_read_preserves_partial_data + with_pipe do |i, o| + o.write("Hello") + i.timeout = 0.0001 + + assert_raise(IO::TimeoutError) {i.read(10)} + assert_equal "Hello", i.read_nonblock(5) + end + end + + def test_timeout_read_preserves_existing_read_buffer + with_pipe do |i, o| + data = "Hello" * 3_276 + "Hell" + o.write("header\n" + data.byteslice(0, 1024)) + writer = Thread.new { o.write(data.byteslice(1024, data.bytesize - 1024)) } + assert_equal "header\n", i.gets + i.timeout = 0.1 + + assert_raise(IO::TimeoutError) {i.read(data.bytesize + 1)} + i.timeout = 1 + assert_equal data, i.read(data.bytesize) + writer.join + end + end + def test_timeout_gets_exception with_pipe do |i, o| i.timeout = 0.0001 From ec2eaf3f072db64023fab54f1db18df78e8fd857 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 25 Sep 2026 20:38:13 +0900 Subject: [PATCH 04/14] Fix shorten-64-to-32 warnings `size_t` may be less than `off_t` --- io_buffer.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_buffer.c b/io_buffer.c index 8e53d82a6699c9..000e0cf9a6f7d6 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -951,7 +951,7 @@ io_buffer_map(int argc, VALUE *argv, VALUE klass) } else if (UNLIKELY((size_t)(file_size - offset) < size)) { size_t maximum_offset = - (file_size - size) / RUBY_IO_BUFFER_MAP_ALIGNMENT * + ((size_t)file_size - size) / RUBY_IO_BUFFER_MAP_ALIGNMENT * RUBY_IO_BUFFER_MAP_ALIGNMENT; rb_raise(rb_eArgError, "Offset (%" PRIsVALUE ") can't be larger than " From 6cf57e66727408b8e6d53217e44af06321e2e68e Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 25 Sep 2026 20:44:57 +0900 Subject: [PATCH 05/14] Add noreturn attribute to never called function --- thread_none.c | 1 + 1 file changed, 1 insertion(+) diff --git a/thread_none.c b/thread_none.c index 58f43796eb06d4..f9a7a9e30fca8f 100644 --- a/thread_none.c +++ b/thread_none.c @@ -321,6 +321,7 @@ rb_ractor_sched_barrier_end(rb_vm_t *vm, rb_ractor_t *cr) // do nothing } +NORETURN(void rb_ractor_sched_wait(rb_execution_context_t *ec, rb_ractor_t *cr, rb_unblock_function_t *ubf, void *ptr)); void rb_ractor_sched_wait(rb_execution_context_t *ec, rb_ractor_t *cr, rb_unblock_function_t *ubf, void *ptr) { From 1a367ebfeac8b4e8b823561df2da464d054e9781 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:27:40 +0900 Subject: [PATCH 06/14] dir.c: Remove USE_NAME_ON_FS_BY_FNMATCH DOSISH is defined only on _WIN32, which has used USE_NAME_ON_FS_REAL_BASENAME since 45df1c24d2, so this code is never compiled. It even passes an undeclared `basename` to do_opendir. Co-Authored-By: Claude Opus 5.5 --- dir.c | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/dir.c b/dir.c index d5e24223c6e448..bb738d4064e228 100644 --- a/dir.c +++ b/dir.c @@ -78,8 +78,6 @@ char *strchr(char*,char); #define USE_NAME_ON_FS_REAL_BASENAME 1 /* platform dependent APIs to * get real basenames */ -#define USE_NAME_ON_FS_BY_FNMATCH 2 /* select the matching - * basename by fnmatch */ #ifdef HAVE_GETATTRLIST # define USE_NAME_ON_FS USE_NAME_ON_FS_REAL_BASENAME @@ -87,8 +85,6 @@ char *strchr(char*,char); # define SIZEUP32(type) RUP32(sizeof(type)) #elif defined _WIN32 # define USE_NAME_ON_FS USE_NAME_ON_FS_REAL_BASENAME -#elif defined DOSISH -# define USE_NAME_ON_FS USE_NAME_ON_FS_BY_FNMATCH #else # define USE_NAME_ON_FS 0 #endif @@ -3041,21 +3037,7 @@ glob_helper( if (magical || recursive) { rb_dirent_t *dp; DIR *dirp; -# if USE_NAME_ON_FS == USE_NAME_ON_FS_BY_FNMATCH - char *plainname = 0; -# endif IF_NORMALIZE_UTF8PATH(int norm_p); -# if USE_NAME_ON_FS == USE_NAME_ON_FS_BY_FNMATCH - if (cur + 1 == end && (*cur)->type <= ALPHA) { - plainname = join_path(path, pathlen, dirsep, (*cur)->str, strlen((*cur)->str)); - if (!plainname) return -1; - dirp = do_opendir(fd, basename, plainname, flags, enc, funcs->error, arg, &status); - GLOB_FREE(plainname); - } - else -# else - ; -# endif dirp = do_opendir(fd, baselen, path, flags, enc, funcs->error, arg, &status); if (dirp == NULL) { # if FNM_SYSCASE || NORMALIZE_UTF8PATH @@ -3193,12 +3175,6 @@ glob_helper( *new_end++ = p->next; break; case ALPHA: -# if USE_NAME_ON_FS == USE_NAME_ON_FS_BY_FNMATCH - if (plainname) { - *new_end++ = p->next; - break; - } -# endif case PLAIN: case MAGICAL: if (dirent_match(p->str, enc, name, dp, flags)) From d65eccb775f230f43dad68476f53fbc7f5b802a1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:28:18 +0900 Subject: [PATCH 07/14] dir.c: Remove redundant case in has_magic On Windows the default case already sets hasalpha for '~' because IS_WIN32 is 1. Co-Authored-By: Claude Opus 5.5 --- dir.c | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dir.c b/dir.c index bb738d4064e228..2f8524a9e64891 100644 --- a/dir.c +++ b/dir.c @@ -2179,10 +2179,6 @@ has_magic(const char *p, const char *pend, int flags, rb_encoding *enc) #ifdef _WIN32 case '.': break; - - case '~': - hasalpha = 1; - break; #endif default: if (IS_WIN32 || ISALPHA(c)) { From 5feb9db04d4478a8fbd153fbf18b1a565e8b22a7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 16:28:58 +0900 Subject: [PATCH 08/14] dir.c: Remove mkdir, rmdir and opendir overrides for Windows include/ruby/win32.h and win32/dir.h have mapped them to the rb_w32_u* functions since 5b98b2ce39. Co-Authored-By: Claude Opus 5.5 --- dir.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dir.c b/dir.c index 2f8524a9e64891..890372d18d007b 100644 --- a/dir.c +++ b/dir.c @@ -130,12 +130,6 @@ char *strchr(char*,char); #ifdef _WIN32 # undef chdir # define chdir(p) rb_w32_uchdir(p) -# undef mkdir -# define mkdir(p, m) rb_w32_umkdir((p), (m)) -# undef rmdir -# define rmdir(p) rb_w32_urmdir(p) -# undef opendir -# define opendir(p) rb_w32_uopendir(p) # define ruby_getcwd() rb_w32_ugetcwd(NULL, 0) # define IS_WIN32 1 #else From 99d1ece935c6b41aa165bc8e2fa09cfe4f287b1f Mon Sep 17 00:00:00 2001 From: Matthias Goergens Date: Sat, 1 Aug 2026 15:50:50 +0800 Subject: [PATCH 09/14] io: preserve wide character boundaries in raw reads --- io.c | 134 +++++++++++++++++++++++++++++++ test/ruby/test_io_m17n.rb | 160 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) diff --git a/io.c b/io.c index d0b5a4be6d27cb..a256e46df8d5e0 100644 --- a/io.c +++ b/io.c @@ -4041,6 +4041,61 @@ search_delim(const char *p, long len, int delim, rb_encoding *enc) return NULL; } +static int +read_raw_character(rb_io_t *fptr, rb_encoding *enc, char *buf) +{ + int n = 0; + int r; + + do { + if (!READ_DATA_PENDING(fptr)) { + READ_CHECK(fptr); + if (io_fillbuf(fptr) < 0) break; + } + buf[n++] = *READ_DATA_PENDING_PTR(fptr); + fptr->rbuf.off++; + fptr->rbuf.len--; + r = rb_enc_precise_mbclen(buf, buf + n, enc); + } while (MBCLEN_NEEDMORE_P(r) && n < rb_enc_mbmaxlen(enc)); + + return n; +} + +static void +unread_raw_character(rb_io_t *fptr, const char *buf, int len) +{ + if (fptr->rbuf.capa - fptr->rbuf.len < len) { + if (fptr->rbuf.capa > INT_MAX - len) + rb_raise(rb_eIOError, "ungetbyte failed"); + fptr->rbuf.capa += len; + REALLOC_N(fptr->rbuf.ptr, char, fptr->rbuf.capa); + } + io_ungetbyte(rb_str_new(buf, len), fptr); +} + +static const char * +search_wide_delim(const char *p, long len, const char *delim, int width) +{ + const char *e = p + len; + int index = 0; + + while (index < width && delim[index] == 0) index++; + if (index == width) index = 0; + + const char *candidate = p + index; + while (candidate < e) { + candidate = memchr(candidate, (unsigned char)delim[index], e - candidate); + if (candidate == NULL) break; + const char *start = candidate - index; + if ((start - p) % width == 0 && start + width <= e && + memcmp(start, delim, width) == 0) { + return start; + } + candidate++; + } + return NULL; +} + static int appendline(rb_io_t *fptr, int delim, VALUE *strp, long *lp, rb_encoding *enc) { @@ -4091,6 +4146,71 @@ appendline(rb_io_t *fptr, int delim, VALUE *strp, long *lp, rb_encoding *enc) } NEED_NEWLINE_DECORATOR_ON_READ_CHECK(fptr); + if (rb_enc_mbminlen(enc) != 1) { + char buf[ONIGENC_CODE_TO_MBC_MAXLEN]; + int len; + + if (limit < 0) { + int width = rb_enc_mbminlen(enc); + int delim_len = rb_enc_codelen(delim, enc); + + if (delim_len == width) { + rb_enc_mbcput(delim, buf, enc); + for (;;) { + if (!READ_DATA_PENDING(fptr)) { + READ_CHECK(fptr); + if (io_fillbuf(fptr) < 0) return EOF; + } + long pending = READ_DATA_PENDING_COUNT(fptr); + long complete = pending - pending % width; + const char *p = READ_DATA_PENDING_PTR(fptr); + const char *q = complete ? search_wide_delim(p, complete, buf, width) : NULL; + long take = q ? q - p + width : complete; + + if (take > 0) { + if (NIL_P(str)) + *strp = str = rb_str_buf_new(0); + rb_str_buf_cat(str, p, take); + fptr->rbuf.off += (int)take; + fptr->rbuf.len -= (int)take; + } + if (q) return delim; + if (complete == pending) continue; + + len = read_raw_character(fptr, enc, buf); + if (len == 0) return EOF; + if (NIL_P(str)) + *strp = str = rb_str_buf_new(0); + rb_str_buf_cat(str, buf, len); + int r = rb_enc_precise_mbclen(buf, buf + len, enc); + if (MBCLEN_CHARFOUND_P(r) && + rb_enc_mbc_to_codepoint(buf, buf + len, enc) == (unsigned int)delim) + return delim; + } + } + } + + while ((len = read_raw_character(fptr, enc, buf)) > 0) { + if (NIL_P(str)) + *strp = str = rb_str_buf_new(0); + rb_str_buf_cat(str, buf, len); + if (limit > 0) { + if (len > limit) { + *lp = 0; + return (unsigned char)buf[len - 1]; + } + *lp = limit -= len; + } + int r = rb_enc_precise_mbclen(buf, buf + len, enc); + if (MBCLEN_CHARFOUND_P(r) && + rb_enc_mbc_to_codepoint(buf, buf + len, enc) == (unsigned int)delim) + return delim; + if (limit == 0) + return (unsigned char)buf[len - 1]; + } + *lp = limit; + return EOF; + } do { long pending = READ_DATA_PENDING_COUNT(fptr); if (pending > 0) { @@ -4154,6 +4274,20 @@ swallow(rb_io_t *fptr, int term) } NEED_NEWLINE_DECORATOR_ON_READ_CHECK(fptr); + rb_encoding *enc = io_read_encoding(fptr); + int widechar = rb_enc_mbminlen(enc) != 1; + if (widechar) { + char buf[ONIGENC_CODE_TO_MBC_MAXLEN]; + int len; + + while ((len = read_raw_character(fptr, enc, buf)) > 0) { + if (rb_enc_ascget(buf, buf + len, NULL, enc) != term) { + unread_raw_character(fptr, buf, len); + return TRUE; + } + } + return FALSE; + } do { size_t cnt; while ((cnt = READ_DATA_PENDING_COUNT(fptr)) > 0) { diff --git a/test/ruby/test_io_m17n.rb b/test/ruby/test_io_m17n.rb index 1736d01f78e3fd..d214369b8fe830 100644 --- a/test/ruby/test_io_m17n.rb +++ b/test/ruby/test_io_m17n.rb @@ -2396,6 +2396,166 @@ def test_binmode_paragraph_nonasciicompat } end + def test_binmode_paragraph_widechar_without_conversion + with_tmpdir do + content = "\n\n\n\nhello\n\n\n\nworld" + [Encoding::UTF_32BE, Encoding::UTF_32LE, + Encoding::UTF_16BE, Encoding::UTF_16LE].each do |e| + encoded = content.encode(e) + File.binwrite("widechar", encoded) + File.open("widechar", "rb", encoding: e) do |f| + assert_equal("hello\n\n".encode(e), f.gets(""), "[Bug #20819]") + assert_equal("world".encode(e), f.gets(""), "[Bug #20819]") + end + + [1, "\n\n\n\nhello".encode(e).bytesize + 1, + "\n\n\n\nhello\n\n".encode(e).bytesize + 1].each do |split| + IO.pipe do |r, w| + r.binmode + r.set_encoding(e) + writer = Thread.new do + w.binmode + w.write(encoded.byteslice(0, split)) + sleep 0.01 + w.write(encoded.byteslice(split..)) + w.close + end + actual = [r.gets(""), r.gets("")] + writer.join + message = "[Bug #20819] #{e} split at byte #{split}" + assert_equal(["hello\n\n".encode(e), "world".encode(e)], + actual, message) + end + end + end + + e = Encoding::UTF_16BE + content = "\n\n\n\nh\n\nworld" + encoded = content.encode(e) + split = "\n\n\n\nh\n".encode(e).bytesize - 1 + IO.pipe do |r, w| + r.binmode + r.set_encoding(e) + writer = Thread.new do + w.binmode + w.write(encoded.byteslice(0, split)) + sleep 0.01 + w.write(encoded.byteslice(split..)) + w.close + end + actual = [r.gets("", 1000), r.gets("", 1000)] + writer.join + assert_equal(["h\n\n".encode(e), "world".encode(e)], actual, + "[Bug #20819] finite limit") + end + + content = "\n\n\n\nh#{'x' * 10_000}\n\nworld" + encoded = content.encode(e) + split = "\n\n\n\nh".encode(e).bytesize - 1 + IO.pipe do |r, w| + r.binmode + r.set_encoding(e) + writer = Thread.new do + w.binmode + w.write(encoded.byteslice(0, split)) + sleep 0.01 + w.write(encoded.byteslice(split..)) + w.close + end + actual = [r.gets(""), r.gets("")] + writer.join + assert_equal(["h#{'x' * 10_000}\n\n".encode(e), "world".encode(e)], + actual, "[Bug #20819] full-buffer lookahead") + end + + e = Encoding::UTF_32BE + content = "#{'x' * 2_048}\n\nworld" + encoded = content.encode(e) + IO.pipe do |r, w| + r.binmode + r.set_encoding(e) + writer = Thread.new do + w.binmode + w.write(encoded.byteslice(0, 8_193)) + sleep 0.01 + w.write(encoded.byteslice(8_193..)) + w.close + end + actual = [r.gets(""), r.gets("")] + writer.join + assert_equal(["#{'x' * 2_048}\n\n".encode(e), "world".encode(e)], + actual, "[Bug #20819] partial character after full buffer") + end + end + end + + def test_binmode_widechar_separator_without_conversion + with_tmpdir do + content = "one\u{3042}two\u{3042}three" + [Encoding::UTF_32BE, Encoding::UTF_32LE, + Encoding::UTF_16BE, Encoding::UTF_16LE].each do |e| + encoded = content.encode(e) + separator = "\u{3042}".encode(e) + File.binwrite("widechar", encoded) + File.open("widechar", "rb", encoding: e) do |f| + assert_equal("one\u{3042}".encode(e), f.gets(separator)) + assert_equal("two\u{3042}".encode(e), f.gets(separator)) + assert_equal("three".encode(e), f.gets(separator)) + end + + split = "one\u{3042}".encode(e).bytesize - 1 + IO.pipe do |r, w| + r.binmode + r.set_encoding(e) + writer = Thread.new do + w.binmode + w.write(encoded.byteslice(0, split)) + sleep 0.01 + w.write(encoded.byteslice(split..)) + w.close + end + actual = [r.gets(separator), r.gets(separator), r.gets(separator)] + writer.join + assert_equal(["one\u{3042}".encode(e), "two\u{3042}".encode(e), + "three".encode(e)], actual) + end + end + end + end + + def test_binmode_widechar_separator_at_limit + with_tmpdir do + [Encoding::UTF_32BE, Encoding::UTF_32LE, + Encoding::UTF_16BE, Encoding::UTF_16LE].each do |e| + width = "A".encode(e).bytesize + + File.binwrite("paragraph", "\n\nA\n\nB".encode(e)) + (1..width + 1).each do |limit| + File.open("paragraph", "rb", encoding: e) do |f| + expected = limit <= width ? "A" : "A\n" + assert_equal(expected.encode(e), f.gets("", limit), + "[Bug #20819] #{e} paragraph limit #{limit}") + end + end + + separator = "\u{3042}".encode(e) + File.binwrite("separator", "A\u{3042}B".encode(e)) + (1..width + 1).each do |limit| + File.open("separator", "rb", encoding: e) do |f| + expected = limit <= width ? "A" : "A\u{3042}" + assert_equal(expected.encode(e), f.gets(separator, limit), + "[Bug #20819] #{e} separator limit #{limit}") + end + end + + File.open("paragraph", "rb", encoding: e) do |f| + assert_equal("A".encode(e), f.gets("", width * 3, chomp: true), + "[Bug #20819] #{e} chomp at exact boundary") + end + end + end + end + def test_puts_widechar bug = '[ruby-dev:42212]' pipe(Encoding::ASCII_8BIT, From 34ae1caa807fac57a302847f40c21f179917e47d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 26 Sep 2026 14:58:38 +0900 Subject: [PATCH 10/14] Change raise to assertions in test_gc.rb --- test/ruby/test_gc.rb | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/test/ruby/test_gc.rb b/test/ruby/test_gc.rb index f46a3852be1db4..9866a02eb1c148 100644 --- a/test/ruby/test_gc.rb +++ b/test/ruby/test_gc.rb @@ -1290,26 +1290,26 @@ def test_stat_global_scope_retains_finished_ractor_history end worker_count, control = ready.receive - raise "worker count" unless worker_count == 3 + assert_equal(3, worker_count, "worker count") live = GC.stat(:count, scope: :global) - raise "live work missing" unless live - process_before == 3 - raise "worker changed main count" unless GC.stat(:count) == local_before + assert_equal(3, live - process_before, "live work missing") + assert_equal(local_before, GC.stat(:count), "worker changed main count") monitor = Ractor::Port.new worker.monitor(monitor) control << :finish - raise "worker did not exit" unless monitor.receive == [worker, :exited] - raise "history lost on exit" unless GC.stat(:count, scope: :global) == live + assert_equal([worker, :exited], monitor.receive, "worker did not exit") + assert_equal(live, GC.stat(:count, scope: :global), "history lost on exit") global_before = GC.stat(:count, scope: :global) GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) - raise "global count" unless GC.stat(:count, scope: :global) - global_before == 1 + assert_equal(1, GC.stat(:count, scope: :global) - global_before, "global count") local_after_global = GC.stat(:count) snapshot = GC.stat(scope: :global) - raise "worker result" unless worker.value == :finish - raise "absorption changed history" unless GC.stat(scope: :global) == snapshot - raise "absorption changed main count" unless GC.stat(:count) == local_after_global + assert_equal(:finish, worker.value, "worker result") + assert_equal(snapshot, GC.stat(scope: :global), "absorption changed history") + assert_equal(local_after_global, GC.stat(:count), "absorption changed main count") RUBY end @@ -1332,16 +1332,18 @@ def test_stat_global_scope_preserves_nested_ractor_history GC.stat(:count) end raise "inner count" unless inner.value == 5 + reply << :ready Ractor.receive end ready.receive - raise "nested history missing" unless GC.stat(:count, scope: :global) - process_before == 8 + + assert_equal(8, GC.stat(:count, scope: :global) - process_before, "nested history missing") outer.send(:finish) - raise "outer result" unless outer.value == :finish - raise "nested history changed" unless GC.stat(:count, scope: :global) - process_before == 8 - raise "main inherited nested counts" unless GC.stat(:count) == local_before + assert_equal(:finish, outer.value, "outer result") + assert_equal(8, GC.stat(:count, scope: :global) - process_before, "nested history changed") + assert_equal(local_before, GC.stat(:count), "main inherited nested counts") RUBY end @@ -1413,7 +1415,7 @@ def test_stat_global_scope_reads_are_coherent_during_ractor_collection worker_control << :go reader_control << :go - raise "reader did not reach halfway" unless ready.receive == :halfway + assert_equal(:halfway, ready.receive, "reader did not reach halfway") assert_equal :done, worker.value reader_control << :continue reader_last = reader.value @@ -1496,7 +1498,7 @@ def test_stat_global_scope_fork_inherits_archived_and_live_history r << GC.stat(:count) Ractor.receive end - raise "live count" unless live_ready.receive == 2 + assert_equal(2, live_ready.receive, "live count") snapshot = GC.stat(scope: :global) assert_equal 5, snapshot[:count] - process_before @@ -1516,7 +1518,7 @@ def test_stat_global_scope_fork_inherits_archived_and_live_history child_after = Marshal.load(read) read.close _, status = Process.waitpid2(pid) - raise "child exit status" unless status.success? + assert_predicate(status, :success?, "child exit status") assert_equal snapshot, child_initial assert_equal 1, child_after[:count] - child_initial[:count] assert_equal snapshot[:count], GC.stat(:count, scope: :global) From 4300f5dda986414b3ddc9957553315e6437c7f1b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 14:26:05 +0900 Subject: [PATCH 11/14] [Feature #21277] Run check on Windows 11-arm 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 bbd469c6da439b..2bf8617c509d05 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -34,7 +34,7 @@ jobs: - os: 2025-vs2026 test_task: test-bundled-gems - os: 11-arm - test_task: 'btest test-basic test-tool' # check and test-spec are broken yet. + test_task: check target: arm64 fail-fast: false From e36d7036602ddd4b740655676a32661e64a27bb1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 15:01:39 +0900 Subject: [PATCH 12/14] Expect arm64 as host_cpu on mswin mswin takes the CPU name from MSVC, as it reports x64 rather than x86_64 on x64. Co-Authored-By: Claude Opus 5.5 --- spec/ruby/library/rbconfig/rbconfig_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/ruby/library/rbconfig/rbconfig_spec.rb b/spec/ruby/library/rbconfig/rbconfig_spec.rb index 4195128a05092b..d25c39c1df4c0f 100644 --- a/spec/ruby/library/rbconfig/rbconfig_spec.rb +++ b/spec/ruby/library/rbconfig/rbconfig_spec.rb @@ -98,11 +98,11 @@ guard -> { %w[aarch64 arm64].include? RbConfig::CONFIG['host_cpu'] } do it "['host_cpu'] returns CPU architecture properly for AArch64" do - platform_is :darwin do + platform_is :darwin, :mswin do RbConfig::CONFIG['host_cpu'].should == 'arm64' end - platform_is_not :darwin do + platform_is_not :darwin, :mswin do RbConfig::CONFIG['host_cpu'].should == 'aarch64' end end From 9fe613e94bc8aa9f78b6afed733b81ea8409058e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sat, 26 Sep 2026 16:35:09 +0900 Subject: [PATCH 13/14] Reap an exited child in EnvUtil.terminate On Windows, Process.kill raises Errno::ESRCH for a child that has exited but has not been reaped, so terminate returned a stale $? and left the child to ZombieHunter. TestIO#test_write_32bit_boundary hit this on Windows 11-arm, where the child finished writing after its timeout expired and before it was killed. Co-Authored-By: Claude Opus 5.5 --- tool/lib/envutil.rb | 2 ++ tool/test/test_envutil.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/tool/lib/envutil.rb b/tool/lib/envutil.rb index 176a8a4bc837f7..5a03a6f4137854 100644 --- a/tool/lib/envutil.rb +++ b/tool/lib/envutil.rb @@ -194,6 +194,8 @@ def terminate(pid, signal = :TERM, pgroup = nil, reprieve = 1) rescue Errno::EINVAL next rescue Errno::ESRCH + # Windows reports ESRCH for a child that has exited but not been reaped + Process.wait(pid, Process::WNOHANG) rescue nil break end if signals.empty? or !reprieve diff --git a/tool/test/test_envutil.rb b/tool/test/test_envutil.rb index 806df6ab059e67..ae508948379078 100644 --- a/tool/test/test_envutil.rb +++ b/tool/test/test_envutil.rb @@ -27,4 +27,14 @@ def test_invoke_ruby_captures_output_and_status assert_equal("err", stderr) assert_predicate(status, :success?) end + + def test_terminate_reaps_exited_child + r, w = IO.pipe + pid = spawn(EnvUtil.rubybin, "-e", "", out: w) + w.close + r.read + r.close + + assert_equal(pid, EnvUtil.terminate(pid).pid) + end end From 227006291d35b2255b3f7a888d75102c129b0472 Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Wed, 23 Sep 2026 00:55:18 +0900 Subject: [PATCH 14/14] Fix IO#gets ignoring the limit argument IO#gets first searches for separator candidates by looking for the last byte of the record separator, and then checks whether it is an actual match. If a candidate is found at limit-th byte of the stream but is rejected because it does not start at a character boundary, the limit check is incorrectly skipped. [Bug #22342] --- io.c | 18 +++++++++--------- test/ruby/test_io_m17n.rb | 11 +++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/io.c b/io.c index a256e46df8d5e0..6617b2cc0d6d6f 100644 --- a/io.c +++ b/io.c @@ -4516,19 +4516,19 @@ rb_io_getline_0(VALUE rs, long limit, int chomp, rb_io_t *fptr) while ((c = appendline(fptr, newline, &str, &limit, enc)) != EOF) { const char *s, *p, *pp, *e; - if (c == newline) { - if (RSTRING_LEN(str) < rslen) continue; + if (c == newline && RSTRING_LEN(str) >= rslen) { s = RSTRING_PTR(str); e = RSTRING_END(str); p = e - rslen; - if (!at_char_boundary(s, p, e, enc)) continue; - if (!rspara) rscheck(rsptr, rslen, rs); - if (memcmp(p, rsptr, rslen) == 0) { - if (chomp) { - if (chomp_cr && p > s && *(p-1) == '\r') --p; - rb_str_set_len(str, p - s); + if (at_char_boundary(s, p, e, enc)) { + if (!rspara) rscheck(rsptr, rslen, rs); + if (memcmp(p, rsptr, rslen) == 0) { + if (chomp) { + if (chomp_cr && p > s && *(p-1) == '\r') --p; + rb_str_set_len(str, p - s); + } + break; } - break; } } if (limit == 0) { diff --git a/test/ruby/test_io_m17n.rb b/test/ruby/test_io_m17n.rb index d214369b8fe830..83b94e1b3e7155 100644 --- a/test/ruby/test_io_m17n.rb +++ b/test/ruby/test_io_m17n.rb @@ -877,6 +877,17 @@ def test_gets_limit proc {|r| assert_equal("\xa4\xa2\xa4\xa4\xa4\xa6\n".force_encoding("euc-jp"), r.gets(9)) }) end + def test_gets_rs_char_boundary + str = "\xa2\xa4\xa4\xa2\xa4\xa4\xa4\xa6\xa4\xa8\xa4\xaa".force_encoding("euc-jp") + rs = "\xa4\xa4".force_encoding("euc-jp") + pipe("euc-jp", + proc {|w| w << str; w.close }, + proc {|r| assert_equal("\xa2\xa4\xa4\xa2\xa4\xa4".force_encoding("euc-jp"), r.gets(rs)) }) + pipe("euc-jp", + proc {|w| w << str; w.close }, + proc {|r| assert_equal("\xa2\xa4\xa4\xa2".force_encoding("euc-jp"), r.gets(rs, 3)) }) + end + def test_gets_invalid before = "\u{3042}\u{3044}" invalid = "\x80".force_encoding("utf-8")