From 7f12b704061770276052f61f0476ffc4f7548503 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 13 Sep 2026 22:47:42 +0200 Subject: [PATCH 1/4] [ruby/erb] Revert NEON vtbl use It's failing compilation on `rmv7a-linux-androideabi30-clang`: ``` escape.c:223:34: error: call to undeclared function 'vqtbl1q_u8'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration] 223 | const uint8x16_t looked_up = vqtbl1q_u8(escape_char_by_low_nibble, low_nibbles); | ^ escape.c:223:34: note: did you mean 'vtbl1_u8'? /home/chkbuild/opt/android-ndk-r29/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/21/include/arm_neon.h:33478:48: note: 'vtbl1_u8' declared here 33478 | __ai __attribute__((target("neon"))) uint8x8_t vtbl1_u8(uint8x8_t __p0, uint8x8_t __p1) { | ^ escape.c:223:22: error: initializing 'const uint8x16_t' (vector of 16 'uint8_t' values) with an expression of incompatible type 'int' 223 | const uint8x16_t looked_up = vqtbl1q_u8(escape_char_by_low_nibble, low_nibbles); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ escape.c:236:14: error: call to undeclared function 'vpaddq_u8'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration] 236 | folded = vpaddq_u8(folded, folded); | ^ escape.c:236:12: error: assigning to 'uint8x16_t' (vector of 16 'uint8_t' values) from incompatible type 'int' 236 | folded = vpaddq_u8(folded, folded); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~ escape.c:237:12: error: assigning to 'uint8x16_t' (vector of 16 'uint8_t' values) from incompatible type 'int' 237 | folded = vpaddq_u8(folded, folded); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~ escape.c:238:12: error: assigning to 'uint8x16_t' (vector of 16 'uint8_t' values) from incompatible type 'int' 238 | folded = vpaddq_u8(folded, folded); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~ escape.c:256:25: error: call to undeclared function 'vpaddq_u8'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration] 256 | uint8x16_t folded = vpaddq_u8(vpaddq_u8(t0, t1), vpaddq_u8(t2, t3)); | ^ escape.c:256:16: error: initializing 'uint8x16_t' (vector of 16 'uint8_t' values) with an expression of incompatible type 'int' 256 | uint8x16_t folded = vpaddq_u8(vpaddq_u8(t0, t1), vpaddq_u8(t2, t3)); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ escape.c:257:12: error: assigning to 'uint8x16_t' (vector of 16 'uint8_t' values) from incompatible type 'int' 257 | folded = vpaddq_u8(folded, folded); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~ ``` https://github.com/ruby/erb/commit/45b481473c --- ext/erb/escape/escape.c | 126 +++++++--------------------------------- 1 file changed, 20 insertions(+), 106 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index 1268aa7b34e020..66cb77ed332a7d 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -172,93 +172,11 @@ find_next_match_neon(search_state *search) // uint64_t >>= 64 is undefined behaviour RUBY_ASSERT(trailing_zeros < 64); search->matches_bitmap >>= trailing_zeros; - search->cstr += trailing_zeros; + search->cstr += trailing_zeros / 4; RUBY_ASSERT(search->cstr <= search->end); return true; } -// This 16-byte lookup table is indexed into by using the -// low nibble of each input byte. -// Note: index 0 is intentionally set to a character that will not match -// the NULL byte. -static const uint8x16_t escape_char_by_low_nibble = { - '\'', 0, '"', 0, - 0, 0, '&', '\'', - 0, 0, 0, 0, - '<', 0, '>', 0, -}; - -static inline uint8x16_t -neon_escape_matches(const uint8x16_t bytes) -{ - // An example to demonstrate how this works. The goal is to get a uint8x16_t - // with each lane to equal 0xFF if the corresponding byte in 'bytes' needs - // to be escaped, or 0x00 otherwise. - // - // To keep things very simple, I'm going to assume a vector of length 6, in - // reality, the vector would be 16 bytes wide. - // - // Assume the string is: "
" - // Converted to integers: - // [0x3c 0x62 0x72 0x20 0x2f 0x3e] - // - // Next, we mask off the top nibble so we are left only with the low nibble - // of each byte. We do this by AND'ing each byte with 0x0F. - // - // The result: - // [0x0c 0x02 0x02 0x00 0x0f 0x0e] - // - // Now, we use these low nibbles as indexes into the - // escape_char_by_low_nibble array and find the full byte - // value we expect to match in the input. - // - // The result: - // [0x3c 0x22 0x22 0x27 0x00 0x3e] - // - // Finally, we compare the bytes we expect with the actual input bytes. - // - // The result: - // [0xFF 0x00 0x00 0x00 0x00 0xFF] - const uint8x16_t low_nibbles = vandq_u8(bytes, vdupq_n_u8(0x0F)); - const uint8x16_t looked_up = vqtbl1q_u8(escape_char_by_low_nibble, low_nibbles); - return vceqq_u8(looked_up, bytes); -} - -static inline uint64_t -neon_matches_to_bitmap16(const uint8x16_t matches) -{ - static const uint8x16_t bit_mask = { - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, - }; - - uint8x16_t folded = vandq_u8(matches, bit_mask); - folded = vpaddq_u8(folded, folded); - folded = vpaddq_u8(folded, folded); - folded = vpaddq_u8(folded, folded); - - return vgetq_lane_u16(vreinterpretq_u16_u8(folded), 0); -} - -static inline uint64_t -neon_matches_to_bitmap64(const uint8x16_t m0, const uint8x16_t m1, const uint8x16_t m2, const uint8x16_t m3) -{ - static const uint8x16_t bit_mask = { - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, - 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, - }; - - const uint8x16_t t0 = vandq_u8(m0, bit_mask); - const uint8x16_t t1 = vandq_u8(m1, bit_mask); - const uint8x16_t t2 = vandq_u8(m2, bit_mask); - const uint8x16_t t3 = vandq_u8(m3, bit_mask); - - uint8x16_t folded = vpaddq_u8(vpaddq_u8(t0, t1), vpaddq_u8(t2, t3)); - folded = vpaddq_u8(folded, folded); - - return vgetq_lane_u64(vreinterpretq_u64_u8(folded), 0); -} - static inline bool find_next_neon(search_state *search) { @@ -266,34 +184,30 @@ find_next_neon(search_state *search) return find_next_match_neon(search); } - while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16x4_t)) { - const uint8x16_t bytes0 = vld1q_u8(search->cstr + 0); - const uint8x16_t bytes1 = vld1q_u8(search->cstr + 16); - const uint8x16_t bytes2 = vld1q_u8(search->cstr + 32); - const uint8x16_t bytes3 = vld1q_u8(search->cstr + 48); - - const uint8x16_t m0 = neon_escape_matches(bytes0); - const uint8x16_t m1 = neon_escape_matches(bytes1); - const uint8x16_t m2 = neon_escape_matches(bytes2); - const uint8x16_t m3 = neon_escape_matches(bytes3); - - const uint64_t bitmap = neon_matches_to_bitmap64(m0, m1, m2, m3); - - if (bitmap) { - search->matches_bitmap = bitmap; - return find_next_match_neon(search); - } - - search->cstr += 64; - } + const uint8x16_t single_quote = vdupq_n_u8('\''); + const uint8x16_t double_quote = vdupq_n_u8('"'); + const uint8x16_t ampersand = vdupq_n_u8('&'); + const uint8x16_t lt = vdupq_n_u8('<'); + const uint8x16_t gt = vdupq_n_u8('>'); while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16_t)) { const uint8x16_t bytes = vld1q_u8(search->cstr); - const uint8x16_t matches = neon_escape_matches(bytes); - const uint64_t bitmap = neon_matches_to_bitmap16(matches); + const uint8x16_t match1 = vceqq_u8(bytes, single_quote); + const uint8x16_t match2 = vceqq_u8(bytes, double_quote); + const uint8x16_t match3 = vceqq_u8(bytes, ampersand); + const uint8x16_t match4 = vceqq_u8(bytes, lt); + const uint8x16_t match5 = vceqq_u8(bytes, gt); + + const uint8x16_t mask1 = vorrq_u8(match1, match2); + const uint8x16_t mask2 = vorrq_u8(match3, match4); + const uint8x16_t mask3 = vorrq_u8(mask1, match5); + const uint8x16_t matches = vorrq_u8(mask2, mask3); + + const uint8x8_t res = vshrn_n_u16(vreinterpretq_u16_u8(matches), 4); + const uint64_t bitmap = vget_lane_u64(vreinterpret_u64_u8(res), 0); if (bitmap) { - search->matches_bitmap = bitmap; + search->matches_bitmap = bitmap & 0x8888888888888888ull; return find_next_match_neon(search); } search->cstr += sizeof(uint8x16_t); From 0f0d3172bea34d1e81163a261bcd020529eb5d18 Mon Sep 17 00:00:00 2001 From: KBS Date: Mon, 14 Sep 2026 06:50:58 +0900 Subject: [PATCH 2/4] [ruby/stringio] Fix the zero-fill bounds in ungetbyteCommon on JRuby (https://github.com/ruby/stringio/pull/225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Arrays.fill(byte[], int, int, byte)` takes **fromIndex, toIndex**, but `ungetbyteCommon` passes `memset`'s **destination, length**: ```java if (rest > cl) Arrays.fill(strBytes, len, rest - cl, (byte) 0); ``` straight from `ext/stringio/stringio.c:1181`, `memset(s + len, 0, rest - cl)`. It also drops the ByteList `begin` offset that the C `s +` supplies. Pushing back onto a StringIO positioned more than the pushback length past the end of its string then throws instead of zero-filling: ```ruby s = StringIO.new("abc"); s.pos = 5; s.ungetbyte("d") # JRuby: Java::JavaLang::IllegalArgumentException: fromIndex(3) > toIndex(1) # CRuby: s.string == "abc\0d", s.pos == 4 ``` The two siblings in this file already spell the correct form — `truncate` (`StringIO.java:1462`) uses `buf.getBegin() + plen, buf.getBegin() + l`, and `strioExtend` (`:1044-1048`) uses `begin + olen, begin + pos`. This one line is the odd one out, so the fix reuses their shape rather than adding anything. The added expectations extend the existing `test_ungetc_padding` / `test_ungetbyte_padding`, which stop one short: both start from `StringIO.new()`, so `len == 0` makes the wrong call accidentally correct.
Verification Both backends built from this clone, `$LOADED_FEATURES` printed and checked on every row. A differential sweep of 2100 cases (7 base strings x 25 positions x 6 pushback values x `ungetbyte`/`ungetc`), JRuby against the C extension: | build | rows differing from CRuby | |---|---| | current `master` | **132** | | with the fix | **0** | Every one of the 132 is the `IllegalArgumentException`. | row | result | |---|---| | the two padding tests on `master` | 2 errors | | with the fix | 2 tests, 12 assertions, 0 failures, 0 errors | | revert | 2 errors | | widen the guard to `rest > 0` | 1 error | | `fromIndex` one low | 1 failure, 1 error | | `toIndex` one high | passes — see below | That last mutant survives, and I checked whether it was a gap in my test or genuinely equivalent: the extra byte lands at `s + pos` after `pos -= cl`, which `System.arraycopy(..., strBytes, s + pos, cl)` two lines later overwrites unconditionally. Sweeping it over the same 2100 cases gives **0 differences from the fixed build**, so it is an equivalent mutant rather than an uncovered line, and I did not add a row for it. Full `test/stringio/test_stringio.rb`: CRuby 3.2 **104 tests, 646 assertions, 0 failures, 0 errors**; JRuby 10.0.6 **104 tests, 643 assertions, 0 failures, 0 errors**. Every added expectation was run against the C extension first, so it encodes CRuby behaviour rather than my reading of it. Two things I want on the record. The `s +` half is not separately covered — I could not construct a StringIO whose backing ByteList has `begin != 0` through the public API. It is included because both siblings and the C both require it; if you would rather keep the change minimal, the two-index correction alone fixes the crash. And `ruby/.github`'s SECURITY.md routes vulnerabilities privately: this is the JVM's own bounds check firing rather than memory unsafety, and I could not produce any stale-byte disclosure, so I filed publicly — say the word if you would rather it had gone the other route. The CI matrix runs `jruby-head`; the closest I could obtain is JRuby 10.0.6. Note `jruby:9.4` cannot compile `ext/java` at master at all (`Helpers.memchr` signature mismatch), so 10.x is already the floor.
--- Disclosure: I used Claude (an AI assistant) while preparing this change. Every result above I ran and verified myself. https://github.com/ruby/stringio/commit/9d3e502b3a --- test/stringio/test_stringio.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/stringio/test_stringio.rb b/test/stringio/test_stringio.rb index 5afa23bc0e6baf..413f5a42585d17 100644 --- a/test/stringio/test_stringio.rb +++ b/test/stringio/test_stringio.rb @@ -899,6 +899,12 @@ def test_ungetc_padding s.pos = 0 s.ungetc("b") assert_equal("b""\0""a", s.string) + + s = StringIO.new("abc") + s.pos = 5 + s.ungetc("d") + assert_equal("abc""\0""d", s.string) + assert_equal(4, s.pos) end def test_ungetc_fill @@ -944,6 +950,24 @@ def test_ungetbyte_padding s.pos = 0 s.ungetbyte("b".ord) assert_equal("b""\0""a", s.string) + + s = StringIO.new("abc") + s.pos = 5 + s.ungetbyte("d".ord) + assert_equal("abc""\0""d", s.string) + assert_equal(4, s.pos) + + s = StringIO.new("abcde") + s.pos = 9 + s.ungetbyte("f") + assert_equal("abcde""\0\0\0""f", s.string) + assert_equal(8, s.pos) + + s = StringIO.new("abc") + s.pos = 4 + s.ungetbyte("de") + assert_equal("abde", s.string) + assert_equal(2, s.pos) end def test_ungetbyte_fill From 9be33d7f9f5043936388de1d6aeca0835af91b8d Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Sun, 13 Sep 2026 19:16:58 -0500 Subject: [PATCH 3/4] [DOC] Harmonize symlink methods --- file.c | 8 ++++++-- pathname_builtin.rb | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/file.c b/file.c index e3e12052e9e2de..0cf60f44da0b61 100644 --- a/file.c +++ b/file.c @@ -3751,11 +3751,12 @@ rb_file_s_link(VALUE klass, VALUE from, VALUE to) * :markup: markdown * * call-seq: - * File.symlink(path, link_path) -> 0 + * File.symlink(target_path, link_path) -> 0 * * Not supported on some platforms. * - * Creates a symbolic link at `link_path` to the entry at `path`: + * Creates a [symbolic link](rdoc-ref:file/symbolic_links.md) + * at `link_path` to the entry at `target_path`: * * ```ruby * # Create paths. @@ -3768,6 +3769,9 @@ rb_file_s_link(VALUE klass, VALUE from, VALUE to) * File.delete(link_path) # Clean up. * ``` * + * If the entry at `target_path` is itself a symlink, that link is _not_ followed; + * thus the created symlink always points to `target_path`. + * * See also: ::read, ::readlink, ::symlink?. */ diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 2191beccd1137a..1a200256bd2b06 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -1914,9 +1914,10 @@ def lstat() File.lstat(@path) end # :markup: markdown # # call-seq: - # make_symlink(path) -> 0 + # make_symlink(target_path) -> 0 # - # Creates a symbolic link at the path in `self` to the entry at `path`: + # Creates a [symbolic link](rdoc-ref:file/symbolic_links.md) + # at the path in `self` to the entry at `target_path`: # # ```ruby # # Create Pathnames. @@ -1929,6 +1930,9 @@ def lstat() File.lstat(@path) end # link_pn.delete # Clean up. # ``` # + # If the entry at `target_path` is itself a symlink, that link is _not_ followed; + # thus the created symlink always points to `target_path`. + # # See also: #read, #readlink, #symlink?. def make_symlink(old) File.symlink(old, @path) end From 377c9ec1cd5808d947bcf589839268f93fae4aff Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sun, 13 Sep 2026 16:55:24 +0900 Subject: [PATCH 4/4] Use Set instead of Hash for Enuemrator::Lazy#uniq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit changes Enuemrator::Lazy#uniq to use a temporary Set instead of Hash for performance and memory savings. We can see in the following benchmark: a = ((0...10_000).to_a + (0...10_000).to_a).lazy 1_000.times { a.uniq.to_a } Results: Benchmark 1: master Time (mean ± σ): 867.5 ms ± 13.6 ms [User: 857.1 ms, System: 7.8 ms] Range (min … max): 853.9 ms … 899.4 ms 10 runs Benchmark 2: branch Time (mean ± σ): 801.4 ms ± 7.1 ms [User: 791.3 ms, System: 7.4 ms] Range (min … max): 789.4 ms … 810.7 ms 10 runs Summary branch ran 1.08 ± 0.02 times faster than master --- enumerator.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/enumerator.c b/enumerator.c index 94c0fa3a03a05d..1dc59143504d88 100644 --- a/enumerator.c +++ b/enumerator.c @@ -29,6 +29,7 @@ #include "internal/numeric.h" #include "internal/range.h" #include "internal/rational.h" +#include "internal/set.h" #include "ruby/ruby.h" /* @@ -2642,14 +2643,14 @@ lazy_drop_while(VALUE obj) static int lazy_uniq_check(VALUE chain, VALUE memos, long memo_index) { - VALUE hash = rb_ary_entry(memos, memo_index); + VALUE set = rb_ary_entry(memos, memo_index); - if (NIL_P(hash)) { - hash = rb_obj_hide(rb_hash_new()); - rb_ary_store(memos, memo_index, hash); + if (NIL_P(set)) { + set = rb_obj_hide(rb_set_new()); + rb_ary_store(memos, memo_index, set); } - return rb_hash_add_new_element(hash, chain, Qfalse); + return !rb_set_add_no_check(set, chain); } static struct MEMO *