From 5bb4a15086adc0424bf9f2bce957a18cc2fa3d48 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Tue, 19 May 2026 10:26:35 +0900 Subject: [PATCH 01/51] mruby-regexp: cap bt_match recursion depth The backtracking engine recurses via C function calls at RE_SPLIT, RE_SPLITNG, RE_SAVE, RE_LOOKAHEAD, RE_NEG_LOOKAHEAD, RE_LOOKBEHIND, and RE_NEG_LOOKBEHIND. Patterns like `(?=)+` make the engine recurse without consuming input, exhausting the C stack and triggering SIGSEGV long before MRB_REGEXP_STEP_LIMIT is reached (each recursion charges only ~1 step, but each frame costs ~150 bytes of stack). Reported by ClusterFuzz testcase clusterfuzz-testcase-minimized-mruby_fuzzer-4653331195953152. Add an integer recursion-depth counter passed alongside the step counter, and abort the current branch with FALSE when it exceeds MRB_REGEXP_RECURSION_LIMIT (default 1000, configurable like STEP_LIMIT). Legitimate patterns nest only a few levels; pathological inputs bail without crashing the VM. Co-authored-by: Claude --- mrbgems/mruby-regexp/include/re_internal.h | 6 ++++++ mrbgems/mruby-regexp/src/re_exec.c | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/mrbgems/mruby-regexp/include/re_internal.h b/mrbgems/mruby-regexp/include/re_internal.h index 07e022677..a03752b67 100644 --- a/mrbgems/mruby-regexp/include/re_internal.h +++ b/mrbgems/mruby-regexp/include/re_internal.h @@ -97,6 +97,12 @@ typedef struct mrb_regexp_pattern { #define MRB_REGEXP_STEP_LIMIT 1000000 #endif +/* Recursion-depth limit for bt_match: bounds C stack growth on + patterns like `(?=)+` that recurse without consuming input. */ +#ifndef MRB_REGEXP_RECURSION_LIMIT +#define MRB_REGEXP_RECURSION_LIMIT 1000 +#endif + /* Maximum captures */ #define RE_MAX_CAPTURES 32 diff --git a/mrbgems/mruby-regexp/src/re_exec.c b/mrbgems/mruby-regexp/src/re_exec.c index 948f6d080..889c60c38 100644 --- a/mrbgems/mruby-regexp/src/re_exec.c +++ b/mrbgems/mruby-regexp/src/re_exec.c @@ -388,8 +388,10 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat, */ static mrb_bool bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, - const char *sp, uint32_t pc, int *captures, int ncap, int *steps) + const char *sp, uint32_t pc, int *captures, int ncap, int *steps, + int depth) { + if (depth > MRB_REGEXP_RECURSION_LIMIT) return FALSE; while (pc < pat->code_len) { if (++(*steps) > MRB_REGEXP_STEP_LIMIT) return FALSE; @@ -428,12 +430,12 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, break; case RE_SPLIT: - if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps)) return TRUE; + if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps, depth + 1)) return TRUE; pc = inst.offset; break; case RE_SPLITNG: - if (bt_match(pat, str, str_end, sp, inst.offset, captures, ncap, steps)) return TRUE; + if (bt_match(pat, str, str_end, sp, inst.offset, captures, ncap, steps, depth + 1)) return TRUE; pc++; break; @@ -443,7 +445,7 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, if (slot < ncap) { int old = captures[slot]; captures[slot] = (int)(sp - str); - if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps)) return TRUE; + if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps, depth + 1)) return TRUE; captures[slot] = old; } return FALSE; @@ -502,13 +504,13 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, break; case RE_LOOKAHEAD: - if (!bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps)) + if (!bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps, depth + 1)) return FALSE; pc = inst.offset; break; case RE_NEG_LOOKAHEAD: - if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps)) + if (bt_match(pat, str, str_end, sp, pc + 1, captures, ncap, steps, depth + 1)) return FALSE; pc = inst.offset; break; @@ -517,7 +519,7 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, { int lb_len = inst.a; if (sp - str < lb_len) return FALSE; /* not enough text before */ - if (!bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps)) + if (!bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps, depth + 1)) return FALSE; pc = inst.offset; } @@ -527,7 +529,7 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, { int lb_len = inst.a; if (sp - str >= lb_len) { - if (bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps)) + if (bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps, depth + 1)) return FALSE; } /* if not enough text before, negative lookbehind succeeds */ @@ -567,7 +569,7 @@ backtrack_exec(mrb_state *mrb, const mrb_regexp_pattern *pat, memset(caps, -1, sizeof(int) * ncap); int steps = 0; - if (bt_match(pat, str, str_end, sp, 0, caps, ncap, &steps)) { + if (bt_match(pat, str, str_end, sp, 0, caps, ncap, &steps, 0)) { if (captures) { int copy = ncap < captures_size ? ncap : captures_size; memcpy(captures, caps, sizeof(int) * copy); From db2845aae05eb4a624eae22892f73ebdd1ee02c3 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Tue, 19 May 2026 10:40:45 +0900 Subject: [PATCH 02/51] mruby-regexp: bounds-check group index in RE_BACKREF The RE_BACKREF execution path read `captures[group * 2]` and `captures[group * 2 + 1]` without verifying that the group index fit in the allocated captures array. A pattern like `/\1/` (no capture group, but a backreference to group 1) is accepted by the compiler and lands in execution with `ncap = 2` (only group 0 slots) and an instruction asking for group 1 -- a 4-byte read past the end of the allocation. Reported by ClusterFuzz testcase clusterfuzz-testcase-minimized-mruby_fuzzer-5474946829844480. Add `if (group * 2 + 1 >= ncap) return FALSE;` ahead of the captures access, mirroring the bounds guard already present in RE_SAVE. The compiler's permissive `\` handling stays unchanged; the runtime now treats a reference to a non-existent group as a non-match rather than UB. Co-authored-by: Claude --- mrbgems/mruby-regexp/src/re_exec.c | 1 + 1 file changed, 1 insertion(+) diff --git a/mrbgems/mruby-regexp/src/re_exec.c b/mrbgems/mruby-regexp/src/re_exec.c index 889c60c38..246398219 100644 --- a/mrbgems/mruby-regexp/src/re_exec.c +++ b/mrbgems/mruby-regexp/src/re_exec.c @@ -492,6 +492,7 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, case RE_BACKREF: { int group = inst.a; + if (group * 2 + 1 >= ncap) return FALSE; int gs = captures[group * 2]; int ge = captures[group * 2 + 1]; if (gs < 0 || ge < 0) return FALSE; From 73255d3b70efc857944658aab7ead6a376b82d1a Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Tue, 19 May 2026 12:05:33 +0900 Subject: [PATCH 03/51] mruby-numeric-ext: avoid signed overflow on Integer#gcd/lcm with MRB_INT_MIN Negating MRB_INT_MIN (-2^63) is signed overflow (UB) because 2^63 does not fit in mrb_int. Both `mrb_int_gcd` and `int_lcm` took the absolute value via `if (x < 0) x = -x`, which trips on MRB_INT_MIN. Reported by ClusterFuzz testcase clusterfuzz-testcase-minimized-mruby_fuzzer-5137605569347584. * mrb_int_gcd: cast each input to mrb_uint before negating; the Euclidean reduction runs in unsigned. The cast back at the end yields MRB_INT_MIN only when the mathematical gcd is 2^63 (i.e., gcd(MIN, 0) or gcd(MIN, MIN)). * int_gcd: detect the negative return value from mrb_int_gcd and raise via mrb_int_overflow, since the true result does not fit. * int_lcm: short-circuit raise when either operand is MRB_INT_MIN (after the existing zero check), since the abs would overflow and the lcm with any non-zero operand could not fit anyway. Co-authored-by: Claude --- mrbgems/mruby-numeric-ext/src/numeric_ext.c | 28 +++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/mrbgems/mruby-numeric-ext/src/numeric_ext.c b/mrbgems/mruby-numeric-ext/src/numeric_ext.c index 1c80a5055..13159b75d 100644 --- a/mrbgems/mruby-numeric-ext/src/numeric_ext.c +++ b/mrbgems/mruby-numeric-ext/src/numeric_ext.c @@ -84,16 +84,20 @@ mrb_value mrb_int_pow(mrb_state *mrb, mrb_value x, mrb_value y); static mrb_int mrb_int_gcd(mrb_int x, mrb_int y) { - if (x < 0) x = -x; - if (y < 0) y = -y; + /* Negate via unsigned so MRB_INT_MIN doesn't overflow. + The cast back at the end produces MRB_INT_MIN only when the + true result is 2^63 (gcd of MRB_INT_MIN with itself or 0); + callers detect that case from the negative return value. */ + mrb_uint ux = (x < 0) ? -(mrb_uint)x : (mrb_uint)x; + mrb_uint uy = (y < 0) ? -(mrb_uint)y : (mrb_uint)y; - while (y != 0) { - mrb_int temp = y; - y = x % y; - x = temp; + while (uy != 0) { + mrb_uint temp = uy; + uy = ux % uy; + ux = temp; } - return x; + return (mrb_int)ux; } /* @@ -122,7 +126,11 @@ int_gcd(mrb_state *mrb, mrb_value x) if (!mrb_integer_p(y)) { mrb_raisef(mrb, E_TYPE_ERROR, "can't convert %Y into Integer", y); } - return mrb_int_value(mrb, mrb_int_gcd(mrb_integer(x), mrb_integer(y))); + mrb_int g = mrb_int_gcd(mrb_integer(x), mrb_integer(y)); + /* g < 0 only when the mathematical result is 2^63 (= |MRB_INT_MIN|), + which does not fit in mrb_int. */ + if (g < 0) mrb_int_overflow(mrb, "gcd"); + return mrb_int_value(mrb, g); } /* @@ -158,6 +166,10 @@ int_lcm(mrb_state *mrb, mrb_value x) if (a == 0 || b == 0) return mrb_int_value(mrb, 0); + /* Negation of MRB_INT_MIN is UB and the lcm with any non-zero + operand would not fit in mrb_int anyway. */ + if (a == MRB_INT_MIN || b == MRB_INT_MIN) mrb_int_overflow(mrb, "lcm"); + gcd_val = mrb_int_gcd(a, b); if (a < 0) a = -a; if (b < 0) b = -b; From ae29ab7db9577fe45a5812eee0b364aaf976bab2 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Tue, 19 May 2026 12:15:38 +0900 Subject: [PATCH 04/51] numeric.c: bounds-check float in Float#div before mrb_int cast `Float#div(Integer)` cast its receiver to mrb_int unconditionally, which is undefined behavior when the float is outside the representable mrb_int range. ASan reports the UB on inputs like `5e+56.div(1)`. Reported by ClusterFuzz testcase clusterfuzz-testcase-minimized-mruby_fuzzer-5137605569347584. Guard the cast with FIXABLE_FLOAT and route over-range receivers to mrb_bint_div when MRB_USE_BIGINT is defined (matching flo_rounding_int's pattern), or raise via mrb_int_overflow when not. Existing `(float in range).div(int)` semantics are unchanged. Co-authored-by: Claude --- src/numeric.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/numeric.c b/src/numeric.c index d3f370d7f..5d695c77e 100644 --- a/src/numeric.c +++ b/src/numeric.c @@ -351,6 +351,14 @@ flo_idiv(mrb_state *mrb, mrb_value xv) mrb_float x = mrb_float(xv); mrb_check_num_exact(mrb, x); mrb_int y = mrb_as_int(mrb, mrb_get_arg1(mrb)); + /* (mrb_int)x is UB when x is outside mrb_int range. */ + if (!FIXABLE_FLOAT(x)) { +#ifdef MRB_USE_BIGINT + return mrb_bint_div(mrb, mrb_bint_new_float(mrb, x), mrb_int_value(mrb, y)); +#else + mrb_int_overflow(mrb, "div"); +#endif + } return mrb_div_int_value(mrb, (mrb_int)x, y); } From 6daa33c9a84dfd5f4593de4280ecb85c733f7cd7 Mon Sep 17 00:00:00 2001 From: dearblue Date: Tue, 19 May 2026 21:22:27 +0900 Subject: [PATCH 05/51] Avoid surrounding `#if` when using `mrb_const_cache_clear()` `mrb_const_cache_clear()` is always available. --- src/variable.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/variable.c b/src/variable.c index 92c56f76b..36a39de18 100644 --- a/src/variable.c +++ b/src/variable.c @@ -1443,9 +1443,7 @@ mrb_const_set(mrb_state *mrb, mrb_value mod, mrb_sym sym, mrb_value v) mrb_class_name_class(mrb, mrb_class_ptr(mod), mrb_class_ptr(v), sym); } mrb_obj_iv_set(mrb, mrb_obj_ptr(mod), sym, v); -#ifndef MRB_NO_CONST_CACHE mrb_const_cache_clear(mrb); -#endif if (!mrb->bootstrapping) { mrb_value name = mrb_symbol_value(sym); @@ -1472,9 +1470,7 @@ mrb_const_remove(mrb_state *mrb, mrb_value mod, mrb_sym sym) { mod_const_check(mrb, mod); mrb_iv_remove(mrb, mod, sym); -#ifndef MRB_NO_CONST_CACHE mrb_const_cache_clear(mrb); -#endif } /* @@ -1492,9 +1488,7 @@ MRB_API void mrb_define_const_id(mrb_state *mrb, struct RClass *mod, mrb_sym name, mrb_value v) { mrb_obj_iv_set(mrb, (struct RObject*)mod, name, v); -#ifndef MRB_NO_CONST_CACHE mrb_const_cache_clear(mrb); -#endif } /* From 6d12f1f331327730fa2c65af5f3ecaf8acd12acb Mon Sep 17 00:00:00 2001 From: dearblue Date: Tue, 19 May 2026 22:28:15 +0900 Subject: [PATCH 06/51] Use `MRB_SYM()` in `gc_stat()` --- src/gc.c | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/gc.c b/src/gc.c index ed5f099fb..a3ad5f090 100644 --- a/src/gc.c +++ b/src/gc.c @@ -1802,21 +1802,21 @@ gc_stat(mrb_state *mrb, mrb_value self) mrb_gc *gc = &mrb->gc; mrb_value hash = mrb_hash_new_capa(mrb, 8); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "live")), mrb_int_value(mrb, (mrb_int)gc->live)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "debt")), mrb_int_value(mrb, gc->gc_debt)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "state")), mrb_int_value(mrb, (mrb_int)gc->state)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "generational")), mrb_bool_value(gc->generational)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "full")), mrb_bool_value(gc->full)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "step_limit")), mrb_int_value(mrb, (mrb_int)gc->step_limit)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "malloc_increase")), mrb_int_value(mrb, (mrb_int)gc->malloc_increase)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "malloc_threshold")), mrb_int_value(mrb, (mrb_int)gc->malloc_threshold)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "symbol_count")), mrb_int_value(mrb, (mrb_int)(MRB_PRESYM_MAX + mrb->symidx))); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "dynamic_symbol_count")), mrb_int_value(mrb, (mrb_int)mrb->dynamic_sym_count)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(live)), mrb_int_value(mrb, (mrb_int)gc->live)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(debt)), mrb_int_value(mrb, gc->gc_debt)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(state)), mrb_int_value(mrb, (mrb_int)gc->state)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(generational)), mrb_bool_value(gc->generational)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(full)), mrb_bool_value(gc->full)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(step_limit)), mrb_int_value(mrb, (mrb_int)gc->step_limit)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(malloc_increase)), mrb_int_value(mrb, (mrb_int)gc->malloc_increase)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(malloc_threshold)), mrb_int_value(mrb, (mrb_int)gc->malloc_threshold)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(symbol_count)), mrb_int_value(mrb, (mrb_int)(MRB_PRESYM_MAX + mrb->symidx))); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(dynamic_symbol_count)), mrb_int_value(mrb, (mrb_int)mrb->dynamic_sym_count)); #ifdef MRB_GC_STATS - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "total")), mrb_int_value(mrb, (mrb_int)gc->gc_total_count)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "minor")), mrb_int_value(mrb, (mrb_int)gc->minor_gc_count)); - mrb_hash_set(mrb, hash, mrb_symbol_value(mrb_intern_lit(mrb, "major")), mrb_int_value(mrb, (mrb_int)gc->major_gc_count)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(total)), mrb_int_value(mrb, (mrb_int)gc->gc_total_count)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(minor)), mrb_int_value(mrb, (mrb_int)gc->minor_gc_count)); + mrb_hash_set(mrb, hash, mrb_symbol_value(MRB_SYM(major)), mrb_int_value(mrb, (mrb_int)gc->major_gc_count)); #endif return hash; From 3e2ce88eab113a9346ff1425039c70270b538090 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Wed, 20 May 2026 08:34:28 +0900 Subject: [PATCH 07/51] hw-spi: wrap splat-bearing method signatures as code in README After f91936b06e kept the splat notation unescaped, prettier kept flagging the standalone `*` in the SPI#write / SPI#transfer headings every CI run. Wrap the two signatures in backticks so prettier treats them as inline code (which they are), and the `*data` form stays unescaped without further conflict. Same convention is already used in mruby-kernel-ext/README.md (e.g. `### \`fail(*args)\``). Co-authored-by: Claude --- mrbgems/hw-spi/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mrbgems/hw-spi/README.md b/mrbgems/hw-spi/README.md index 50d5e78af..a29403bc5 100644 --- a/mrbgems/hw-spi/README.md +++ b/mrbgems/hw-spi/README.md @@ -50,7 +50,7 @@ spi = SPI.new( \*SPI3 availability depends on ESP32 variant. -### SPI#write(*data) +### `SPI#write(*data)` Write data to the SPI bus. Data can be Integer, Array, or String. @@ -68,7 +68,7 @@ data = spi.read(4) # transmits 0x00 while reading data = spi.read(4, 0xFF) # transmits 0xFF while reading ``` -### SPI#transfer(*data, additional_read_bytes: 0) +### `SPI#transfer(*data, additional_read_bytes: 0)` Full-duplex transfer. Sends data and returns received bytes. Use `additional_read_bytes:` to append zero-filled read bytes. From 17d124b00d3ff5298e6810c4a60d664a6a63195c Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Wed, 20 May 2026 14:27:22 +0900 Subject: [PATCH 08/51] array.c (mrb_ary_splice): re-modify `a` after self-aset ary_dup `a[range] = a` on a long-enough array tripped a heap-buffer-overflow in value_move(). mrb_ary_splice's self-aset branch calls ary_dup(a) to get an independent copy of the source elements, but ary_dup -> ary_replace converts the source to shared as a copy-on-write optimization when the length exceeds ARY_REPLACE_SHARED_MIN. After that, a->as.heap.aux is reinterpreted as `shared` (the union member) and ARY_CAPA(a) reads from the shared pointer's bits rather than the real capacity. The expand-capa check below then silently mis- sizes and value_move walks past the buffer. Re-modify `a` immediately after ary_dup to un-share before the in- place mutation. The buffer reads through `argv` (which now points into the dup's storage) stay valid because ary_modify on a multi- reference shared array allocates a fresh buffer for `a` and leaves the original buffer owned by the dup. Found via clusterfuzz mruby_fuzzer testcase 6525563811725312; regression test covers a[3, 2] = a on a 31-element array (above the ARY_REPLACE_SHARED_MIN=20 threshold). Co-Authored-By: Claude Opus 4.7 --- src/array.c | 8 ++++++++ test/t/array.rb | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/array.c b/src/array.c index 8396e9a15..166960f2f 100644 --- a/src/array.c +++ b/src/array.c @@ -1197,6 +1197,14 @@ mrb_ary_splice(mrb_state *mrb, mrb_value ary, mrb_int head, mrb_int len, mrb_val } r = ary_dup(mrb, a); argv = ARY_PTR(r); + /* ary_dup -> ary_replace converts `a` to shared as a + copy-on-write optimization when len > ARY_REPLACE_SHARED_MIN. + Subsequent ARY_CAPA(a) reads would land on aux.shared's + pointer bits instead of the actual capacity, so the + expand-capa check below silently mis-sizes and value_move + walks past the buffer. Re-modify here to unshare before + mutating `a` in place. */ + ary_modify(mrb, a); } } else if (mrb_undef_p(rpl)) { diff --git a/test/t/array.rb b/test/t/array.rb index ab34264f0..bccdbb815 100644 --- a/test/t/array.rb +++ b/test/t/array.rb @@ -107,6 +107,17 @@ assert('Array#[]=', '15.2.12.5.5') do a = [1,2,3] a[-1,0] = a assert_equal([1,2,1,2,3,3], a) + + # passing self with length above ARY_REPLACE_SHARED_MIN (=20). + # ary_dup -> ary_replace converts the source to shared as a + # copy-on-write optimization; without re-modifying `a` afterwards, + # ARY_CAPA(a) reads from aux.shared's pointer bits and the + # expand-capa check silently mis-sizes -> heap-buffer-overflow in + # value_move. Reported via clusterfuzz mruby_fuzzer. + a = (0..30).to_a + a[3, 2] = a + assert_equal(60, a.length) + assert_equal([0, 1, 2] + (0..30).to_a + (5..30).to_a, a) end assert('Array#clear', '15.2.12.5.6') do From 2c0bccab6679fa6fcf022067e23981de8b394e94 Mon Sep 17 00:00:00 2001 From: dearblue Date: Wed, 20 May 2026 21:15:54 +0900 Subject: [PATCH 09/51] Use `else` to skip the `while` loop in `incremental_sweep_phase()` As a result, the variables can also be localized. --- src/gc.c | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/gc.c b/src/gc.c index a3ad5f090..0654646c1 100644 --- a/src/gc.c +++ b/src/gc.c @@ -1216,36 +1216,37 @@ incremental_sweep_phase(mrb_state *mrb, mrb_gc *gc, size_t limit) size_t tried_sweep = 0; while (page && (tried_sweep < limit)) { - RVALUE *p = page->objects; - RVALUE *e = p + MRB_HEAP_PAGE_SIZE; size_t freed = 0; mrb_bool dead_slot = TRUE; if (is_minor_gc(gc) && page->old) { /* skip a slot which doesn't contain any young object */ - p = e; dead_slot = FALSE; } - while (pas.basic)) { - if (p->as.basic.tt != MRB_TT_FREE) { - obj_free(mrb, &p->as.basic, FALSE); - if (p->as.basic.tt == MRB_TT_FREE) { - p->as.free.next = page->freelist; - page->freelist = p; - freed++; - } - else { - dead_slot = FALSE; + else { + RVALUE *p = page->objects; + RVALUE *e = p + MRB_HEAP_PAGE_SIZE; + while (pas.basic)) { + if (p->as.basic.tt != MRB_TT_FREE) { + obj_free(mrb, &p->as.basic, FALSE); + if (p->as.basic.tt == MRB_TT_FREE) { + p->as.free.next = page->freelist; + page->freelist = p; + freed++; + } + else { + dead_slot = FALSE; + } } } + else { + if (!is_generational(gc)) + paint_partial_white(gc, &p->as.basic); /* next gc target */ + dead_slot = FALSE; + } + p++; } - else { - if (!is_generational(gc)) - paint_partial_white(gc, &p->as.basic); /* next gc target */ - dead_slot = FALSE; - } - p++; } /* free dead slot */ From df38160d820136d674856e2d5518011edd2798da Mon Sep 17 00:00:00 2001 From: dearblue Date: Wed, 20 May 2026 21:15:54 +0900 Subject: [PATCH 10/51] Omit the check whether the typetag is `MRB_TT_FREE` after calling `obj_free()` With commit cbc3dbedb4cd76f90e13ca3b2a32d213ce5b0d51, freed objects will always be `MRB_TT_FREE`. --- src/gc.c | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/gc.c b/src/gc.c index 0654646c1..f8021c6b1 100644 --- a/src/gc.c +++ b/src/gc.c @@ -1230,14 +1230,10 @@ incremental_sweep_phase(mrb_state *mrb, mrb_gc *gc, size_t limit) if (is_dead(gc, &p->as.basic)) { if (p->as.basic.tt != MRB_TT_FREE) { obj_free(mrb, &p->as.basic, FALSE); - if (p->as.basic.tt == MRB_TT_FREE) { - p->as.free.next = page->freelist; - page->freelist = p; - freed++; - } - else { - dead_slot = FALSE; - } + mrb_assert(p->as.basic.tt == MRB_TT_FREE); + p->as.free.next = page->freelist; + page->freelist = p; + freed++; } } else { From 54c8427df209d215adb7de7dd55369cba4a0f8d3 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 08:23:15 +0900 Subject: [PATCH 11/51] mruby-regexp: handle `\b` inside character class as backspace Inside `[...]`, `\b` denotes U+0008 (backspace) -- the same as MRI/Onigmo and PCRE. parse_escape() was missing the case, so the backslash was dropped and the bare letter `b` was inserted into the class. `[\b]` therefore matched every `b` instead of backspace. Add `case 'b': return '\b';` to parse_escape(). The function is only reached from the character-class body and range endpoints; the top-level dispatcher emits RE_WBOUND for `\b` before falling through, so the word-boundary semantics outside `[...]` are unchanged. Reported by Sam Ruby in matz/spinel#632; same engine bug affects both spinel and mruby. Co-authored-by: Claude --- mrbgems/mruby-regexp/src/re_compile.c | 2 ++ mrbgems/mruby-regexp/test/regexp.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/mrbgems/mruby-regexp/src/re_compile.c b/mrbgems/mruby-regexp/src/re_compile.c index 9ffcab203..6ea625913 100644 --- a/mrbgems/mruby-regexp/src/re_compile.c +++ b/mrbgems/mruby-regexp/src/re_compile.c @@ -214,6 +214,8 @@ parse_escape(re_compiler *c) case 'v': return '\v'; case 'a': return '\a'; case 'e': return 0x1b; + case 'b': return '\b'; /* backspace; only reachable inside [...] since the + top-level dispatcher emits RE_WBOUND for `\b` */ default: return ch; /* literal: \., \\, \/, \(, etc. */ } } diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index 8f1499c27..e6e0a0bb6 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -47,6 +47,14 @@ assert("Regexp - character class") do assert_equal "abc", md[0] end +assert("Regexp - \\b inside character class is backspace") do + # Outside [...], \b is the word boundary assertion; inside [...] + # it must mean U+0008 (backspace), matching MRI/Onigmo. + assert_equal "Ruby", "Ruby".gsub(/[\b]/, "X") + assert_equal "aXc", "a\bc".gsub(/[\b]/, "X") + assert_equal ["\b", "\t", "\n"], "ABC\b\t\n".scan(/[\b-\n]/) +end + assert("Regexp - dot") do re = Regexp.new("a.c") assert_true re.match?("abc") From 465e634d486053988f4600afd5414f8919938a04 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 12:32:40 +0900 Subject: [PATCH 12/51] mruby-regexp: fix SEGV on uninitialized Regexp's hash and == regexp_init() called re_compile() before setting @source / @flags IVs, so a Regexp that survived a compile-time exception (e.g. picked up via ObjectSpace.each_object after `Regexp.new("(")` raised) was left with no @source. obj.hash then dereferenced nil through mrb_str_hash() and crashed. Set the IVs before re_compile(), and make regexp_hash / regexp_eql defensive against a non-String @source so Regexp.allocate.hash also behaves. Co-authored-by: Claude --- mrbgems/mruby-regexp/src/regexp.c | 15 ++++++++++----- mrbgems/mruby-regexp/test/regexp.rb | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/mrbgems/mruby-regexp/src/regexp.c b/mrbgems/mruby-regexp/src/regexp.c index 4ecbbf0d3..325fd553f 100644 --- a/mrbgems/mruby-regexp/src/regexp.c +++ b/mrbgems/mruby-regexp/src/regexp.c @@ -107,15 +107,17 @@ regexp_init(mrb_state *mrb, mrb_value self) flags = parse_flags(mrb, flags_val); } + /* Set @source and @flags before re_compile() so a Regexp that survives + a compile-time exception (e.g. picked up by ObjectSpace.each_object) + still has usable IVs for hash/eql?/inspect. */ + mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@source"), pattern); + mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@flags"), mrb_int_value(mrb, (mrb_int)flags)); + pat = re_compile(mrb, RSTRING_PTR(pattern), RSTRING_LEN(pattern), flags); DATA_TYPE(self) = ®exp_type; DATA_PTR(self) = pat; - /* store source for #source and #inspect */ - mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@source"), pattern); - mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@flags"), mrb_int_value(mrb, (mrb_int)flags)); - /* store named captures as hash */ if (pat->num_named > 0) { mrb_value nc = mrb_hash_new_capa(mrb, pat->num_named); @@ -364,6 +366,9 @@ regexp_eql(mrb_state *mrb, mrb_value self) } mrb_value src1 = mrb_iv_get(mrb, self, mrb_intern_lit(mrb, "@source")); mrb_value src2 = mrb_iv_get(mrb, other, mrb_intern_lit(mrb, "@source")); + if (!mrb_string_p(src1) || !mrb_string_p(src2)) { + return mrb_bool_value(mrb_obj_eq(mrb, self, other)); + } if (!mrb_str_equal(mrb, src1, src2)) return mrb_false_value(); return mrb_bool_value(get_iflags(mrb, self) == get_iflags(mrb, other)); } @@ -375,7 +380,7 @@ static mrb_value regexp_hash(mrb_state *mrb, mrb_value self) { mrb_value src = mrb_iv_get(mrb, self, mrb_intern_lit(mrb, "@source")); - uint32_t h = mrb_str_hash(mrb, src); + uint32_t h = mrb_string_p(src) ? mrb_str_hash(mrb, src) : 0; h ^= get_iflags(mrb, self) * 0x9e3779b9; /* mix flags into hash */ return mrb_int_value(mrb, (mrb_int)h); } diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index e6e0a0bb6..a37f3374f 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -181,6 +181,17 @@ assert("Regexp#hash") do assert_not_equal r1.hash, r3.hash end +assert("Regexp#hash/== on uninitialized regexp") do + # Regexp.allocate yields an object with no @source IV; hash/== must + # not crash (regression: ObjectSpace.each_object could expose a + # half-initialized Regexp after Regexp.new raised a compile error). + r = Regexp.allocate + assert_kind_of Integer, r.hash + assert_true r == r + assert_false r == Regexp.allocate + assert_false r == Regexp.new("abc") +end + assert("Regexp#options") do assert_equal 0, Regexp.new("abc").options assert_equal Regexp::IGNORECASE, Regexp.new("abc", Regexp::IGNORECASE).options From 72c117cb9ef97533e6445b84eb5463ce46333338 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 15:19:36 +0900 Subject: [PATCH 13/51] variable.c: guard assign_class_name against unresolvable symbol mrb_sym_name_len() returns NULL when sym is 0, out of range, or references a freed symbol slot. assign_class_name() indexed [0] without checking, so malformed bytecode whose OP_CLASS operand indexed past irep->slen could feed a bogus sym here and crash on NULL[0]. Skip the class-naming side effect when the sym does not resolve to a name. close #6842 Co-authored-by: Claude --- src/variable.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/variable.c b/src/variable.c index 36a39de18..401692b19 100644 --- a/src/variable.c +++ b/src/variable.c @@ -665,7 +665,8 @@ assign_class_name(mrb_state *mrb, struct RObject *obj, mrb_sym sym, mrb_value v) { if (namespace_p(mrb_type(v))) { struct RObject *c = mrb_obj_ptr(v); - if (obj != c && ISUPPER(mrb_sym_name_len(mrb, sym, NULL)[0])) { + const char *name = mrb_sym_name_len(mrb, sym, NULL); + if (obj != c && name && ISUPPER(name[0])) { mrb_sym id_classname = MRB_SYM(__classname__); mrb_value o = mrb_obj_iv_get(mrb, c, id_classname); From 6ac2c3dc56f3c420e56d40e6060b89cc4cf3b95c Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 17:04:08 +0900 Subject: [PATCH 14/51] mruby-bigint: maintain canonical sn=0 when trim reduces sz to 0 mpz_t treats sn (sign) as the canonical "is zero" flag (zero_p(x) := (x)->sn == 0). When an arithmetic operation produces a value whose limbs trim to zero size, sn must be reset to 0 to preserve the invariant. Several call sites already enforced this locally (e.g. mpz_sub line 616); make trim() responsible so every caller benefits. Without this, an inconsistent zero bignum (sn!=0, sz=0) can flow into mpz_sqr, miss the zero_p guard, and reach mpz_init_heap with hint=0 where mpn_zero(NULL, 0) invokes UB (memset() declares its first argument nonnull). The trip survives at runtime on glibc but is formally undefined behavior, flagged by UBSan via Integer#pow(b,e,m) with specific operands. close #6849 Co-authored-by: Claude --- mrbgems/mruby-bigint/core/bigint.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mrbgems/mruby-bigint/core/bigint.c b/mrbgems/mruby-bigint/core/bigint.c index 549b9df19..5c64c7d87 100644 --- a/mrbgems/mruby-bigint/core/bigint.c +++ b/mrbgems/mruby-bigint/core/bigint.c @@ -373,6 +373,8 @@ trim(mpz_t *x) while (x->sz && x->p[x->sz-1] == 0) { x->sz--; } + /* Maintain invariant: sz == 0 implies sn == 0 (zero is canonical). */ + if (x->sz == 0) x->sn = 0; } /* z = x + y, without regard for sign */ From 206b9f74779cef53e3e56188f1cc41ab752f4e86 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 17:07:51 +0900 Subject: [PATCH 15/51] mruby-bigint: pre-reduce base in mpz_powm_montgomery to satisfy REDC REDC's input T must satisfy T < R*N; mpz_powm_montgomery() computed T = base * R^2 without first reducing base modulo n. When base >= n, T exceeds R*N and REDC silently truncates upper limbs, producing a wrong base_mont and ultimately a wrong result. Use mpz_mmod (general division path) to pre-reduce base mod n before the multiplication by R^2. Visible effect: (2**160).pow(2, (2**40)+1) returned 0 instead of 1. A separate pre-existing issue in mpz_mod's Barrett path (broken precondition check) means that path could return a wrong reduction for large operands; mmod sidesteps that path entirely. Co-authored-by: Claude --- mrbgems/mruby-bigint/core/bigint.c | 14 +++++++++++--- mrbgems/mruby-bigint/test/bigint.rb | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/mrbgems/mruby-bigint/core/bigint.c b/mrbgems/mruby-bigint/core/bigint.c index 5c64c7d87..3ffce860d 100644 --- a/mrbgems/mruby-bigint/core/bigint.c +++ b/mrbgems/mruby-bigint/core/bigint.c @@ -5250,12 +5250,19 @@ mpz_powm_montgomery(mpz_ctx_t *ctx, mpz_t *result, mpz_init(ctx, &one_mont); mpz_montgomery_reduce(ctx, &one_mont, &R2, n, rho); - /* Convert base to Montgomery form: base_mont = base * R mod n = REDC(base * R^2) */ - mpz_t base_mont, temp; + /* Convert base to Montgomery form: base_mont = base * R mod n = REDC(base * R^2). + * REDC requires its input T to satisfy T < R*N. If `base` is not already + * reduced (e.g. base >= n), `base * R^2` can exceed R*N and REDC produces + * a wrong result. Pre-reduce base modulo n via mpz_mmod (the general + * division path) -- both operands are non-negative here so this is + * semantically equivalent to mpz_mod. */ + mpz_t base_mont, base_reduced, temp; mpz_init(ctx, &base_mont); + mpz_init(ctx, &base_reduced); mpz_init_temp(ctx, &temp, n->sz * 4); - mpz_mul(ctx, &temp, (mpz_t*)base, &R2); + mpz_mmod(ctx, &base_reduced, (mpz_t*)base, (mpz_t*)n); + mpz_mul(ctx, &temp, &base_reduced, &R2); mpz_montgomery_reduce(ctx, &base_mont, &temp, n, rho); /* Initialize accumulator to 1 in Montgomery form */ @@ -5287,6 +5294,7 @@ mpz_powm_montgomery(mpz_ctx_t *ctx, mpz_t *result, mpz_clear(ctx, &R2); mpz_clear(ctx, &one_mont); mpz_clear(ctx, &base_mont); + mpz_clear(ctx, &base_reduced); mpz_clear(ctx, &temp); mpz_clear(ctx, &acc); pool_restore(ctx, pool_state); diff --git a/mrbgems/mruby-bigint/test/bigint.rb b/mrbgems/mruby-bigint/test/bigint.rb index 289e93da8..d05043c36 100644 --- a/mrbgems/mruby-bigint/test/bigint.rb +++ b/mrbgems/mruby-bigint/test/bigint.rb @@ -150,6 +150,20 @@ assert 'Bigint pow' do # assert_equal(-1041439304, n.pow(n, -1234567890)) end +assert 'Bigint Integer#pow(e, m) - Montgomery path' do + # Regression: mpz_powm_montgomery() failed to pre-reduce base mod n, + # producing wrong results when base >= n. Also trim() must restore + # the canonical sn=0 when sz becomes 0, otherwise an inconsistent + # zero bignum (sn!=0, sz=0) propagates through the squaring loop. + m = (2**40) + 1 + assert_equal 1, (2**160).pow(2, m) + assert_equal 1, (2**320).pow(2, m) + assert_equal 8, ((2**160) + 1).pow(3, m) + m2 = (2**100) + 3 + assert_equal (3**500) % m2, (3**500).pow(1, m2) + assert_equal ((5**300) ** 7) % m2, (5**300).pow(7, m2) +end + assert 'Bigint abs' do n = 1<<65 assert_equal 36893488147419103232, n.abs From a4e12dfc4276dc4f8f71486912ced6a16b238504 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 16:28:35 +0000 Subject: [PATCH 16/51] build(deps): bump https://github.com/rubocop/rubocop Bumps the pre-commit-hooks group with 1 update: [https://github.com/rubocop/rubocop](https://github.com/rubocop/rubocop). Updates `https://github.com/rubocop/rubocop` from v1.86.1 to 1.86.2 - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.86.1...v1.86.2) --- updated-dependencies: - dependency-name: https://github.com/rubocop/rubocop dependency-version: 1.86.2 dependency-type: direct:production dependency-group: pre-commit-hooks ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0128bdd6a..928f2df46 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,7 +118,7 @@ repos: types: [markdown] files: \.md$ - repo: https://github.com/rubocop/rubocop - rev: v1.86.1 + rev: v1.86.2 hooks: - id: rubocop name: run rubocop From 4507b4a633dcaeb9ac89aa5a30c083951d225cdb Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 21 May 2026 17:37:33 +0900 Subject: [PATCH 17/51] mruby-bigint: gate mpz_mod's Barrett path on the algorithm's precondition Barrett reduction requires x < 2^(2*bits(m)); the gate condition only required x->sz >= y->sz + 2, which let x.sz reach 25 limbs against a 4-limb modulus. When the precondition is violated, mpz_barrett_reduce silently truncates high limbs and returns garbage. Integer#remainder, which routes through mpz_mod, was affected: (3**500).remainder((2**100)+3) returned the wrong value. Integer#% took the udiv path via mpz_mmod and was unaffected. Add x->sz <= 2 * y->sz to the gate so out-of-range inputs fall through to the general udiv path. Co-authored-by: Claude --- mrbgems/mruby-bigint/core/bigint.c | 7 +++++-- mrbgems/mruby-bigint/test/bigint.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/mrbgems/mruby-bigint/core/bigint.c b/mrbgems/mruby-bigint/core/bigint.c index 3ffce860d..839798b2e 100644 --- a/mrbgems/mruby-bigint/core/bigint.c +++ b/mrbgems/mruby-bigint/core/bigint.c @@ -3149,8 +3149,11 @@ mpz_mod(mpz_ctx_t *ctx, mpz_t *r, mpz_t *x, mpz_t *y) return; } - /* Barrett reduction for moderate-sized moduli (>= 4 limbs where setup is worthwhile) */ - if (y->sz >= 4 && y->sz <= 16 && x->sz >= y->sz + 2) { + /* Barrett reduction for moderate-sized moduli (>= 4 limbs where setup is worthwhile). + * Barrett's precondition is x < 2^(2*bits(m)); inputs beyond ~2*m.sz limbs + * violate it and the algorithm silently truncates high limbs. Fall through + * to general division for those. */ + if (y->sz >= 4 && y->sz <= 16 && x->sz >= y->sz + 2 && x->sz <= 2 * y->sz) { mpz_t mu; mpz_init_temp(ctx, &mu, y->sz + 1); mpz_barrett_mu(ctx, &mu, y); diff --git a/mrbgems/mruby-bigint/test/bigint.rb b/mrbgems/mruby-bigint/test/bigint.rb index d05043c36..4f8827edd 100644 --- a/mrbgems/mruby-bigint/test/bigint.rb +++ b/mrbgems/mruby-bigint/test/bigint.rb @@ -164,6 +164,18 @@ assert 'Bigint Integer#pow(e, m) - Montgomery path' do assert_equal ((5**300) ** 7) % m2, (5**300).pow(7, m2) end +assert 'Bigint Integer#remainder large operand' do + # Regression: mpz_mod's Barrett path didn't enforce its precondition + # x < 2^(2*bits(m)), so it silently truncated high limbs when x was + # much larger than m^2, producing the wrong remainder. Integer#% + # took the udiv path and worked, but Integer#remainder went through + # mpz_mod and was broken. + m = (2**100) + 3 + assert_equal (3**500) % m, (3**500).remainder(m) + assert_equal (5**500) % ((2**150) + 1), (5**500).remainder((2**150) + 1) + assert_equal (2**400) % ((2**130) + 1), (2**400).remainder((2**130) + 1) +end + assert 'Bigint abs' do n = 1<<65 assert_equal 36893488147419103232, n.abs From 66f438d8fe69b66b0adf2d515270a972c7d6644f Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 22 May 2026 07:08:11 +0900 Subject: [PATCH 18/51] mruby-bin-debugger: return on OOM in mrb_debug_set_break_method mrb_debug_set_break_method() freed set_class after mrdb_strdup() of method_name failed but did not return. Execution continued into alloc_breakpoint(), which on failure double-freed set_class, or on success stored the dangling pointer in the breakpoint table for later use-after-free. Return MRB_DEBUG_NOBUF immediately after the free. mrdb_strdup uses mrb_malloc_simple which returns NULL on OOM (it does not raise), so the NULL check is reachable in practice. close #6851 Co-authored-by: Claude --- mrbgems/mruby-bin-debugger/tools/mrdb/apibreak.c | 1 + 1 file changed, 1 insertion(+) diff --git a/mrbgems/mruby-bin-debugger/tools/mrdb/apibreak.c b/mrbgems/mruby-bin-debugger/tools/mrdb/apibreak.c index 942b9c4cd..6eb4973f1 100644 --- a/mrbgems/mruby-bin-debugger/tools/mrdb/apibreak.c +++ b/mrbgems/mruby-bin-debugger/tools/mrdb/apibreak.c @@ -239,6 +239,7 @@ mrb_debug_set_break_method(mrb_state *mrb, mrb_debug_context *dbg, const char *c set_method = mrdb_strdup(mrb, method_name); if (set_method == NULL) { mrb_free(mrb, set_class); + return MRB_DEBUG_NOBUF; } index = alloc_breakpoint(dbg, MRB_DEBUG_BPTYPE_METHOD); From 309b99efa5ae989e4ddc171a8e362678435b0bf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:54:27 +0000 Subject: [PATCH 19/51] build(deps): bump the github-actions-dependencies group with 2 updates Bumps the github-actions-dependencies group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [j178/prek-action](https://github.com/j178/prek-action). Updates `github/codeql-action` from 4.35.4 to 4.35.5 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba) Updates `j178/prek-action` from 2.0.3 to 2.0.4 - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/6ad80277337ad479fe43bd70701c3f7f8aa74db3...bdca6f102f98e2b4c7029491a53dfd366469e33d) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-dependencies - dependency-name: j178/prek-action dependency-version: 2.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-dependencies ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/pre-commit-manual.yml | 2 +- .github/workflows/pre-commit.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e82b5eb81..df1c2e59a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,12 +22,12 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: category: "Security" diff --git a/.github/workflows/pre-commit-manual.yml b/.github/workflows/pre-commit-manual.yml index 0c176e5d4..bd7ac01c6 100644 --- a/.github/workflows/pre-commit-manual.yml +++ b/.github/workflows/pre-commit-manual.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 with: install-only: true - name: Run manual pre-commit hooks diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index c0e1d3f38..cec73055d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,6 +15,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 with: extra-args: --all-files From c0d8abd0a312059c75464659598336b99492dd05 Mon Sep 17 00:00:00 2001 From: vobloeb <76634406+vobloeb@users.noreply.github.com> Date: Fri, 22 May 2026 17:01:38 +0000 Subject: [PATCH 20/51] test/bintest.rb: tokenize ENV['EMULATOR'] via Shellwords.split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Command::CrossTestRunner#emulator` returns a shell-quoted string, which `Build#run_test` un-quotes via `sh`, but `CrossBuild#run_bintest` propagates verbatim through `ENV['EMULATOR']` to `test/bintest.rb`. The latter splices it into an Open3 exec-mode argv, where the literal `"` survives into `execve(2)` and the kernel returns `ENOENT`. Switching to `Shellwords.split(ENV['EMULATOR'])` round-trips the quoted string correctly and also fixes multi-token emulator commands (e.g. `qemu-aarch64 -L /sysroot`), which currently end up concatenated into `argv[0]`. Verified against mruby `3.3.0`, `3.4.0`, `4.0.0`, and `master`. Cross-build of mruby for `aarch64-unknown-linux-musl` (qemu-user 8.2.10): bintests went from 17/75 crashing → 100/100 passing. --- test/bintest.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/bintest.rb b/test/bintest.rb index 94bdf10ca..e5ec309ff 100644 --- a/test/bintest.rb +++ b/test/bintest.rb @@ -1,4 +1,5 @@ $:.unshift File.dirname(File.dirname(File.expand_path(__FILE__))) +require 'shellwords' require 'test/assert.rb' GEMNAME = "" @@ -16,7 +17,7 @@ def cmd_list(s) path_list = [cmd_bin(s)] emu = ENV['EMULATOR'] - path_list.unshift emu if emu && !emu.empty? + path_list.unshift(*Shellwords.split(emu)) if emu && !emu.empty? path_list end From cddaec72646e1b792d80ff5950480ac9cf17bfa1 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 22 May 2026 18:39:38 +0900 Subject: [PATCH 21/51] mruby-compiler: bail out of array-literal pattern match opt on splat The optimization that skips `deconstruct` and the size check when the case/in value is an array literal trusted node count, ignoring splat. An element like `*a` expands at runtime, so [*a] was treated as length 1 and matched only patterns of that length. Fixes #6854. Co-authored-by: Claude --- mrbgems/mruby-compiler/core/codegen.c | 40 +++++++++++++++------------ test/t/syntax.rb | 15 ++++++++++ 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/mrbgems/mruby-compiler/core/codegen.c b/mrbgems/mruby-compiler/core/codegen.c index 7a695cdc5..cf3c24d19 100644 --- a/mrbgems/mruby-compiler/core/codegen.c +++ b/mrbgems/mruby-compiler/core/codegen.c @@ -4285,6 +4285,22 @@ codegen_case(codegen_scope *s, node *varnode, int val) */ static void codegen_pattern(codegen_scope *s, node *pattern, int target, uint32_t *fail_pos, int known_array_len); +/* Return the static element count of an array literal AST node, or -1 if any + * element is a splat (whose runtime length is unknown). + */ +static int +array_literal_known_len(node *value) +{ + if (node_type(value) != NODE_ARRAY) return -1; + struct mrb_ast_array_node *arr = array_node(value); + int len = 0; + for (node *elem = arr->elements; elem; elem = elem->cdr) { + if (is_splat_node(elem->car)) return -1; + len++; + } + return len; +} + /* Pattern matching case/in expression */ static void codegen_case_match(codegen_scope *s, node *varnode, int val) @@ -4298,13 +4314,7 @@ codegen_case_match(codegen_scope *s, node *varnode, int val) uint32_t tmp; /* Check if value is an array literal - allows optimizations in pattern matching */ - int known_array_len = -1; - if (node_type(value) == NODE_ARRAY) { - struct mrb_ast_array_node *arr = array_node(value); - node *elem; - known_array_len = 0; - for (elem = arr->elements; elem; elem = elem->cdr) known_array_len++; - } + int known_array_len = array_literal_known_len(value); /* Generate code for the case value */ codegen(s, value, VAL); @@ -6552,16 +6562,15 @@ codegen(codegen_scope *s, node *tree, int val) /* Optimize: array literal => array pattern with matching sizes */ if (node_type(mp->value) == NODE_ARRAY && node_type(mp->pattern) == NODE_PAT_ARRAY) { - struct mrb_ast_array_node *arr = array_node(mp->value); struct mrb_ast_pat_array_node *pat = pat_array_node(mp->pattern); /* Only optimize for exact match (no rest, no post) */ if (pat->rest == 0 && pat->post == NULL) { - /* Count array elements and pattern pre elements */ - int arr_len = 0, pat_len = 0; + /* Count array elements (bail if splat present) and pattern pre elements */ + int arr_len = array_literal_known_len(mp->value); + int pat_len = 0; node *e; - for (e = arr->elements; e; e = e->cdr) arr_len++; for (e = pat->pre; e; e = e->cdr) pat_len++; - if (arr_len == pat_len) { + if (arr_len >= 0 && arr_len == pat_len) { /* Sizes match - skip deconstruct and size check */ int arr_reg = cursp(); int i = 0; @@ -6600,12 +6609,7 @@ codegen(codegen_scope *s, node *tree, int val) head = cursp(); /* Check if value is array literal for optimization */ - if (node_type(mp->value) == NODE_ARRAY) { - struct mrb_ast_array_node *arr = array_node(mp->value); - node *elem; - known_array_len = 0; - for (elem = arr->elements; elem; elem = elem->cdr) known_array_len++; - } + known_array_len = array_literal_known_len(mp->value); /* Evaluate the value */ codegen(s, mp->value, VAL); diff --git a/test/t/syntax.rb b/test/t/syntax.rb index 90f2bd6ac..5f43a1bfa 100644 --- a/test/t/syntax.rb +++ b/test/t/syntax.rb @@ -1043,6 +1043,21 @@ assert('pattern matching - array patterns') do x end assert_equal 3, result + + # array literal with splat as case value (#6854): + # the array-literal-length optimization must bail out for splat, + # since the runtime length is unknown statically. + a = [1, 2] + result = case [*a] + in [1, 2] then :match + else :nomatch + end + assert_equal :match, result + + # same bug in one-line `in` pattern + assert_true ([*a] in [1, 2]) + assert_false ([*a] in [1, 2, 3]) + assert_true ([1, *a, 4] in [1, 1, 2, 4]) end assert('pattern matching - find patterns') do From b9b8186f0019ac99c536d6ac912a8f8ad4b4637d Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 22 May 2026 19:02:49 +0900 Subject: [PATCH 22/51] mruby-regexp: don't bump jump offsets that already point at insertion site insert_inst was incrementing every offset >= pos, but an offset equal to pos already points to the new instruction's slot -- bumping it shifts the target onto whatever code got displaced (typically the body of the quantified atom). For patterns like /a?b?/ the SPLIT for `a?` then landed on `CHAR 'b'` instead of the new SPLIT for `b?`, so the "skip a" thread tried to consume 'b' and died, and both atoms failed to match zero characters at once. Fixes #6853. Co-authored-by: Claude --- mrbgems/mruby-regexp/src/re_compile.c | 8 ++++++-- mrbgems/mruby-regexp/test/regexp.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/mrbgems/mruby-regexp/src/re_compile.c b/mrbgems/mruby-regexp/src/re_compile.c index 6ea625913..c136722a5 100644 --- a/mrbgems/mruby-regexp/src/re_compile.c +++ b/mrbgems/mruby-regexp/src/re_compile.c @@ -91,12 +91,16 @@ insert_inst(re_compiler *c, uint32_t pos, uint8_t op, uint8_t a, uint16_t offset c->code[pos].a = a; c->code[pos].offset = offset; - /* fix all jump targets that point at or past the insertion point */ + /* Fix jump targets that point past the insertion point. An offset equal + to `pos` already points to the inserted instruction's new location and + must not be bumped -- bumping it would shift the target to whatever + code got displaced by the insertion (e.g. the body of the quantified + atom), corrupting "skip past this atom" jumps emitted earlier. */ for (uint32_t i = 0; i < c->code_len; i++) { if (i == pos) continue; switch (c->code[i].op) { case RE_JMP: case RE_SPLIT: case RE_SPLITNG: - if (c->code[i].offset >= pos && c->code[i].offset < 0xffff) { + if (c->code[i].offset > pos && c->code[i].offset < 0xffff) { c->code[i].offset++; } break; diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index a37f3374f..b4dc6a237 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -463,3 +463,18 @@ assert("$1-$9 cleared on no match") do /xyz/ =~ "abc" assert_nil $1 end + +assert("Regexp - consecutive optional quantifiers (#6853)") do + # insert_inst was over-incrementing jump offsets that pointed *at* the + # insertion site, sending earlier "skip this atom" SPLITs into the next + # atom's body. Two adjacent zero-matchable atoms then both failed even + # when both should match zero characters. + assert_equal ["a", nil], /\Aa(b)?c?\z/.match("a").to_a + assert_equal ["ab", "b"], /\Aa(b)?c?\z/.match("ab").to_a + assert_equal ["ac", nil], /\Aa(b)?c?\z/.match("ac").to_a + assert_equal ["abc", "b"], /\Aa(b)?c?\z/.match("abc").to_a + + assert_equal [""], /a?b?/.match("").to_a + assert_equal [""], /a*b*/.match("").to_a + assert_equal [""], /a?b?c?d?/.match("").to_a +end From d21eceb286adaf6c69ceeb6eafaa1a234f1cdf29 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 22 May 2026 19:16:24 +0900 Subject: [PATCH 23/51] mruby-regexp: disable first-byte skip when pattern can match empty first_set_walk returned TRUE when it reached RE_MATCH via epsilon transitions, but that's exactly the case where the optimization is wrong: an empty-matchable pattern can start matching at any position, including bytes that aren't in the computed first-byte set. The skip-ahead loop in pike_vm then advanced past valid empty-match positions, producing a match at the wrong offset (e.g. /a?/.match("b") reported the empty match at index 1 instead of 0). Co-authored-by: Claude --- mrbgems/mruby-regexp/src/re_compile.c | 10 ++++++++-- mrbgems/mruby-regexp/test/regexp.rb | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mrbgems/mruby-regexp/src/re_compile.c b/mrbgems/mruby-regexp/src/re_compile.c index c136722a5..79bb9109d 100644 --- a/mrbgems/mruby-regexp/src/re_compile.c +++ b/mrbgems/mruby-regexp/src/re_compile.c @@ -823,12 +823,18 @@ first_set_walk(const re_inst *code, uint32_t code_len, case RE_ANY: case RE_ANY_NL: return FALSE; /* any byte possible */ case RE_MATCH: - return TRUE; /* empty match; first_bytes still valid for other branches */ + /* Reaching MATCH via epsilon transitions means the regex can match + zero characters at any position. Skipping bytes that aren't in the + first-byte set would skip past valid empty-match positions, so the + optimization isn't safe -- bail out and accept any starting byte. */ + return FALSE; default: return FALSE; } } - return TRUE; + /* Walked off the end without hitting MATCH or a consuming op. Treat as + empty-matchable, same as RE_MATCH. */ + return FALSE; } static mrb_bool diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index b4dc6a237..d3e24d25a 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -478,3 +478,17 @@ assert("Regexp - consecutive optional quantifiers (#6853)") do assert_equal [""], /a*b*/.match("").to_a assert_equal [""], /a?b?c?d?/.match("").to_a end + +assert("Regexp - empty-matchable patterns find earliest match position") do + # When a regex can match zero characters via epsilon transitions, the + # first-byte skip-ahead optimization is unsafe: skipping past bytes + # that aren't in the first-byte set would also skip past valid + # empty-match positions. + md = /a?/.match("b") + assert_equal "", md[0] + assert_equal 0, md.begin(0) + + md = /a?b?/.match("c") + assert_equal "", md[0] + assert_equal 0, md.begin(0) +end From 36dd9eab88c994d6586ff63cbd0436675436dd83 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Sun, 24 May 2026 10:31:14 +0900 Subject: [PATCH 24/51] vformat.rb: fix format/arg type mismatch in %!d test case The test pushed mrb_int via vf.i but the format read int via %!d. On x86_64/aarch64 the va_arg slots overlap so reading the low 32 bits of the pushed int64 returned the right value, but on strict-alignment ABIs (MIPS o32) va_arg(ap, int) reads the alignment padding and the format prints 0 instead of the value. Make the format match the helper: vf.i pairs with %!i (reads mrb_int), matching the surrounding lines 44-50 convention and the "inspect mrb_int" label. Reported by vobloeb in #6857. Co-authored-by: Claude --- test/t/vformat.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/t/vformat.rb b/test/t/vformat.rb index 956870e02..06a30a969 100644 --- a/test/t/vformat.rb +++ b/test/t/vformat.rb @@ -42,7 +42,7 @@ assert('mrb_vformat') do assert_equal '`S`: {a: 1, "b" => "c"}', vf.v('`S`: %S', {a: 1, "b" => ?c}) assert_equal 'percent: %', vf.z('percent: %%') assert_equal '"I": inspect char', vf.c('%!c: inspect char', ?I) - assert_equal '709: inspect mrb_int', vf.i('%!d: inspect mrb_int', 709) + assert_equal '709: inspect mrb_int', vf.i('%!i: inspect mrb_int', 709) assert_equal '"a\x00b\xff"', vf.l('%!l', "a\000b\xFFc\000d", 4) assert_equal ':"&.": inspect symbol', vf.n('%!n: inspect symbol', :'&.') assert_equal 'inspect "String"', vf.v('inspect %!v', 'String') From f6564c7db1e867bec97bd62f103adffb49d7be09 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Mon, 25 May 2026 06:06:05 +0900 Subject: [PATCH 25/51] class.c: make define_method_m static The helper is used only inside class.c (mrb_mod_define_method_m, mod_define_method, define_singleton_method) -- no need to expose it through libmruby.a. Drops one of the non-`mrb_*` linkage symbols flagged in #6858. Co-authored-by: Claude --- src/class.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/class.c b/src/class.c index 7b216a516..74d021ce7 100644 --- a/src/class.c +++ b/src/class.c @@ -3966,7 +3966,7 @@ mrb_method_added(mrb_state *mrb, struct RClass *c, mrb_sym mid) } } -mrb_value +static mrb_value define_method_m(mrb_state *mrb, struct RClass *c, int vis) { mrb_sym mid; From 19c857a77324e54609e5800aebaa580e788de9d1 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Mon, 25 May 2026 06:09:11 +0900 Subject: [PATCH 26/51] mruby-regexp: prefix exposed engine entry points with `mrb_re_` The Pike VM and pattern compiler were exporting bare names like `re_compile`, `re_exec`, `re_free`, `re_is_word_char`, `re_utf8_charlen`, `re_utf8_decode`. `re_exec` in particular collides with the obsolete BSD libc function of the same name (still present on FreeBSD/NetBSD base), so embedding mruby alongside platform regex could surface a link-time symbol clash. Rename all six entry points to `mrb_re_*` to keep the gem's external symbols inside mruby's namespace. Source file names and the public header path are unchanged. Refs #6858. Co-authored-by: Claude --- mrbgems/mruby-regexp/include/re_internal.h | 14 +++++------ mrbgems/mruby-regexp/src/re_compile.c | 6 ++--- mrbgems/mruby-regexp/src/re_exec.c | 28 +++++++++++----------- mrbgems/mruby-regexp/src/re_utf8.c | 6 ++--- mrbgems/mruby-regexp/src/regexp.c | 18 +++++++------- mrbgems/mruby-regexp/test/regexp.rb | 2 +- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/mrbgems/mruby-regexp/include/re_internal.h b/mrbgems/mruby-regexp/include/re_internal.h index a03752b67..5a60696af 100644 --- a/mrbgems/mruby-regexp/include/re_internal.h +++ b/mrbgems/mruby-regexp/include/re_internal.h @@ -76,7 +76,7 @@ typedef struct mrb_regexp_pattern { uint8_t first_bytes[16]; /* bitmap of possible first bytes (128-bit, ASCII) */ mrb_bool has_first_bytes; /* true if first_bytes is usable for skipping */ mrb_bool is_literal; /* true if pattern is pure literal (no metacharacters) */ - /* Cached VM state for pike_vm (avoids malloc per re_exec call) */ + /* Cached VM state for pike_vm (avoids malloc per mrb_re_exec call) */ uint32_t *cached_visited; /* generation-based visited array */ void *cached_threads[2]; /* curr/next thread lists */ int cached_list_capa; /* capacity of cached thread lists */ @@ -113,21 +113,21 @@ typedef struct { } re_thread_cache; /* Compile a pattern string into bytecode */ -mrb_regexp_pattern* re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags); +mrb_regexp_pattern* mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags); /* Free a compiled pattern */ -void re_free(mrb_state *mrb, mrb_regexp_pattern *pat); +void mrb_re_free(mrb_state *mrb, mrb_regexp_pattern *pat); /* Execute a match. Returns number of captures filled (0 = no match). captures[2*n] = start, captures[2*n+1] = end for group n. */ -int re_exec(mrb_state *mrb, const mrb_regexp_pattern *pat, +int mrb_re_exec(mrb_state *mrb, const mrb_regexp_pattern *pat, const char *str, mrb_int len, mrb_int start, int *captures, int captures_size); /* UTF-8 helpers */ -int re_utf8_charlen(const char *s, const char *end); -uint32_t re_utf8_decode(const char *s, int *len); -mrb_bool re_is_word_char(uint32_t c); +int mrb_re_utf8_charlen(const char *s, const char *end); +uint32_t mrb_re_utf8_decode(const char *s, int *len); +mrb_bool mrb_re_is_word_char(uint32_t c); #endif /* MRB_RE_INTERNAL_H */ diff --git a/mrbgems/mruby-regexp/src/re_compile.c b/mrbgems/mruby-regexp/src/re_compile.c index 79bb9109d..edbb96890 100644 --- a/mrbgems/mruby-regexp/src/re_compile.c +++ b/mrbgems/mruby-regexp/src/re_compile.c @@ -183,7 +183,7 @@ class_add_shorthand(re_charclass *cc, int ch) break; case 'W': for (int i = 0; i < 128; i++) { - if (!re_is_word_char(i)) class_set_bit(cc, (uint8_t)i); + if (!mrb_re_is_word_char(i)) class_set_bit(cc, (uint8_t)i); } cc->utf8_any = TRUE; break; @@ -857,7 +857,7 @@ compute_first_set(const re_inst *code, uint32_t code_len, } mrb_regexp_pattern* -re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags) +mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags) { re_compiler c; memset(&c, 0, sizeof(c)); @@ -984,7 +984,7 @@ re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags) } void -re_free(mrb_state *mrb, mrb_regexp_pattern *pat) +mrb_re_free(mrb_state *mrb, mrb_regexp_pattern *pat) { if (pat) { mrb_free(mrb, pat->code); diff --git a/mrbgems/mruby-regexp/src/re_exec.c b/mrbgems/mruby-regexp/src/re_exec.c index 246398219..8f6e04731 100644 --- a/mrbgems/mruby-regexp/src/re_exec.c +++ b/mrbgems/mruby-regexp/src/re_exec.c @@ -170,16 +170,16 @@ add_thread(pike_state *s, re_threadlist *list, case RE_WBOUND: { - mrb_bool before = (sp > s->str) && re_is_word_char((uint8_t)sp[-1]); - mrb_bool after = (sp < s->str_end) && re_is_word_char((uint8_t)*sp); + mrb_bool before = (sp > s->str) && mrb_re_is_word_char((uint8_t)sp[-1]); + mrb_bool after = (sp < s->str_end) && mrb_re_is_word_char((uint8_t)*sp); if (before != after) { pc++; continue; } } return; case RE_NWBOUND: { - mrb_bool before = (sp > s->str) && re_is_word_char((uint8_t)sp[-1]); - mrb_bool after = (sp < s->str_end) && re_is_word_char((uint8_t)*sp); + mrb_bool before = (sp > s->str) && mrb_re_is_word_char((uint8_t)sp[-1]); + mrb_bool after = (sp < s->str_end) && mrb_re_is_word_char((uint8_t)*sp); if (before == after) { pc++; continue; } } return; @@ -301,7 +301,7 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat, next.count = 0; int ch = (uint8_t)*sp; - int advance = re_utf8_charlen(sp, str_end); + int advance = mrb_re_utf8_charlen(sp, str_end); for (int i = 0; i < curr.count; i++) { re_thread *th = &curr.threads[i]; @@ -404,22 +404,22 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, case RE_ANY: if (sp >= str_end || *sp == '\n') return FALSE; - sp += re_utf8_charlen(sp, str_end); pc++; + sp += mrb_re_utf8_charlen(sp, str_end); pc++; break; case RE_ANY_NL: if (sp >= str_end) return FALSE; - sp += re_utf8_charlen(sp, str_end); pc++; + sp += mrb_re_utf8_charlen(sp, str_end); pc++; break; case RE_CLASS: if (sp >= str_end || !class_match(&pat->classes[inst.a], (uint8_t)*sp)) return FALSE; - sp += re_utf8_charlen(sp, str_end); pc++; + sp += mrb_re_utf8_charlen(sp, str_end); pc++; break; case RE_NCLASS: if (sp >= str_end || class_match(&pat->classes[inst.a], (uint8_t)*sp)) return FALSE; - sp += re_utf8_charlen(sp, str_end); pc++; + sp += mrb_re_utf8_charlen(sp, str_end); pc++; break; case RE_MATCH: @@ -473,8 +473,8 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, case RE_WBOUND: { - mrb_bool before = (sp > str) && re_is_word_char((uint8_t)sp[-1]); - mrb_bool after = (sp < str_end) && re_is_word_char((uint8_t)*sp); + mrb_bool before = (sp > str) && mrb_re_is_word_char((uint8_t)sp[-1]); + mrb_bool after = (sp < str_end) && mrb_re_is_word_char((uint8_t)*sp); if (before == after) return FALSE; } pc++; @@ -482,8 +482,8 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end, case RE_NWBOUND: { - mrb_bool before = (sp > str) && re_is_word_char((uint8_t)sp[-1]); - mrb_bool after = (sp < str_end) && re_is_word_char((uint8_t)*sp); + mrb_bool before = (sp > str) && mrb_re_is_word_char((uint8_t)sp[-1]); + mrb_bool after = (sp < str_end) && mrb_re_is_word_char((uint8_t)*sp); if (before != after) return FALSE; } pc++; @@ -611,7 +611,7 @@ literal_exec(const mrb_regexp_pattern *pat, /* Public entry point */ int -re_exec(mrb_state *mrb, const mrb_regexp_pattern *pat, +mrb_re_exec(mrb_state *mrb, const mrb_regexp_pattern *pat, const char *str, mrb_int len, mrb_int start, int *captures, int captures_size) { diff --git a/mrbgems/mruby-regexp/src/re_utf8.c b/mrbgems/mruby-regexp/src/re_utf8.c index 82f22b1bb..148d59858 100644 --- a/mrbgems/mruby-regexp/src/re_utf8.c +++ b/mrbgems/mruby-regexp/src/re_utf8.c @@ -9,7 +9,7 @@ /* Return byte length of UTF-8 character at s. Returns 1 for invalid sequences (treat as single byte). */ int -re_utf8_charlen(const char *s, const char *end) +mrb_re_utf8_charlen(const char *s, const char *end) { uint8_t c = (uint8_t)*s; int len; @@ -28,7 +28,7 @@ re_utf8_charlen(const char *s, const char *end) /* Decode a UTF-8 character and return its codepoint. *len is set to the byte length consumed. */ uint32_t -re_utf8_decode(const char *s, int *len) +mrb_re_utf8_decode(const char *s, int *len) { uint8_t c = (uint8_t)s[0]; uint32_t cp; @@ -66,7 +66,7 @@ re_utf8_decode(const char *s, int *len) /* Check if character is a "word" character (\w): [a-zA-Z0-9_] */ mrb_bool -re_is_word_char(uint32_t c) +mrb_re_is_word_char(uint32_t c) { if (c >= 'a' && c <= 'z') return TRUE; if (c >= 'A' && c <= 'Z') return TRUE; diff --git a/mrbgems/mruby-regexp/src/regexp.c b/mrbgems/mruby-regexp/src/regexp.c index 325fd553f..fa06557f5 100644 --- a/mrbgems/mruby-regexp/src/regexp.c +++ b/mrbgems/mruby-regexp/src/regexp.c @@ -19,7 +19,7 @@ /* Regexp data type */ static void regexp_free(mrb_state *mrb, void *ptr) { - re_free(mrb, (mrb_regexp_pattern*)ptr); + mrb_re_free(mrb, (mrb_regexp_pattern*)ptr); } static const struct mrb_data_type regexp_type = { "Regexp", regexp_free }; @@ -107,13 +107,13 @@ regexp_init(mrb_state *mrb, mrb_value self) flags = parse_flags(mrb, flags_val); } - /* Set @source and @flags before re_compile() so a Regexp that survives + /* Set @source and @flags before mrb_re_compile() so a Regexp that survives a compile-time exception (e.g. picked up by ObjectSpace.each_object) still has usable IVs for hash/eql?/inspect. */ mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@source"), pattern); mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@flags"), mrb_int_value(mrb, (mrb_int)flags)); - pat = re_compile(mrb, RSTRING_PTR(pattern), RSTRING_LEN(pattern), flags); + pat = mrb_re_compile(mrb, RSTRING_PTR(pattern), RSTRING_LEN(pattern), flags); DATA_TYPE(self) = ®exp_type; DATA_PTR(self) = pat; @@ -206,7 +206,7 @@ exec_match(mrb_state *mrb, mrb_value self, mrb_value str, mrb_int pos) int cap_size = pat->num_captures * 2; int *captures = (int*)mrb_malloc(mrb, sizeof(int) * cap_size); memset(captures, -1, sizeof(int) * cap_size); - int ncap = re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, + int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, captures, cap_size); if (ncap == 0) { @@ -244,7 +244,7 @@ regexp_match_p(mrb_state *mrb, mrb_value self) mrb_regexp_pattern *pat = DATA_GET_PTR(mrb, self, ®exp_type, mrb_regexp_pattern); if (!pat) mrb_raise(mrb, E_ARGUMENT_ERROR, "uninitialized Regexp"); - int ncap = re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, NULL, 0); + int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, NULL, 0); return mrb_bool_value(ncap > 0); } @@ -281,7 +281,7 @@ regexp_case_match(mrb_state *mrb, mrb_value self) pat = DATA_GET_PTR(mrb, self, ®exp_type, mrb_regexp_pattern); if (!pat) return mrb_false_value(); - int ncap = re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), 0, NULL, 0); + int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), 0, NULL, 0); return mrb_bool_value(ncap > 0); } @@ -726,7 +726,7 @@ regexp_gsub_str(mrb_state *mrb, mrb_value self) while (pos <= slen) { memset(captures, -1, sizeof(int) * cap_size); - int n = re_exec(mrb, pat, s, slen, pos, captures, cap_size); + int n = mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size); if (n == 0) break; /* save last match for $~ */ @@ -800,7 +800,7 @@ regexp_sub_str(mrb_state *mrb, mrb_value self) int *captures = (int*)mrb_malloc(mrb, sizeof(int) * cap_size); memset(captures, -1, sizeof(int) * cap_size); - int n = re_exec(mrb, pat, s, slen, 0, captures, cap_size); + int n = mrb_re_exec(mrb, pat, s, slen, 0, captures, cap_size); if (n == 0) { mrb_free(mrb, captures); clear_match_globals(mrb); @@ -858,7 +858,7 @@ regexp_scan(mrb_state *mrb, mrb_value self) while (pos <= slen) { memset(captures, -1, sizeof(int) * cap_size); - int n = re_exec(mrb, pat, s, slen, pos, captures, cap_size); + int n = mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size); if (n == 0) break; last_ncap = cap_size; diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index d3e24d25a..787c6898d 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -381,7 +381,7 @@ assert("MatchData#named_captures") do end assert("Regexp - named captures survive /x preprocessing") do - # Regression: with /x, re_compile freed the stripped buffer that + # Regression: with /x, mrb_re_compile freed the stripped buffer that # named_captures[i].name pointed into. re = /(?\d+) # comment \s* (?\w+) /x From 4f398f6126c20f40a277abb5e554e3f532cdddef Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Mon, 25 May 2026 06:48:13 +0900 Subject: [PATCH 27/51] mruby-task: prefix queue helpers with `mrb_task_` `q_insert_task` and `q_delete_task` were exporting bare `q_*` names from libmruby.a -- single-letter prefixes don't belong to the gem's namespace and risk colliding with anything else linked in. Rename to `mrb_task_q_insert` / `mrb_task_q_delete`, matching the `mrb_task_*` convention already used for the rest of the gem's externally visible symbols. Callers in task.c and task_queue.c are updated to the new names. Closes #6858. Co-authored-by: Claude --- mrbgems/mruby-task/include/task.h | 4 +-- mrbgems/mruby-task/src/task.c | 50 ++++++++++++++--------------- mrbgems/mruby-task/src/task_queue.c | 12 +++---- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/mrbgems/mruby-task/include/task.h b/mrbgems/mruby-task/include/task.h index db5e8b7c5..0719fe018 100644 --- a/mrbgems/mruby-task/include/task.h +++ b/mrbgems/mruby-task/include/task.h @@ -166,8 +166,8 @@ task_check_scheduler_lock(mrb_state *mrb) } /* Priority-queue insert/delete - defined in task.c */ -void q_insert_task(mrb_state *mrb, mrb_task *t); -void q_delete_task(mrb_state *mrb, mrb_task *t); +void mrb_task_q_insert(mrb_state *mrb, mrb_task *t); +void mrb_task_q_delete(mrb_state *mrb, mrb_task *t); /* Task::Queue class registration - defined in task_queue.c */ void mrb_init_task_queue(mrb_state *mrb, struct RClass *task_class); diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index e6fdfc4fb..70f9e6233 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -148,7 +148,7 @@ q_get_queue(mrb_state *mrb, mrb_task *t) /* Insert task into queue based on priority (higher priority = lower number = earlier in queue) */ void -q_insert_task(mrb_state *mrb, mrb_task *t) +mrb_task_q_insert(mrb_state *mrb, mrb_task *t) { mrb_task **q = q_get_queue(mrb, t); mrb_task *curr = *q; @@ -172,7 +172,7 @@ q_insert_task(mrb_state *mrb, mrb_task *t) /* Delete task from its current queue */ void -q_delete_task(mrb_state *mrb, mrb_task *t) +mrb_task_q_delete(mrb_state *mrb, mrb_task *t) { mrb_task **q = q_get_queue(mrb, t); mrb_task *curr = *q; @@ -202,10 +202,10 @@ task_cleanup_if_stopped(mrb_state *mrb, mrb_task *t) if (t->status == MRB_TASK_STATUS_DORMANT || t->c.status == MRB_TASK_STOPPED) { /* Task is terminated but still in queue - remove it */ mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); if (t->status != MRB_TASK_STATUS_DORMANT) { t->status = MRB_TASK_STATUS_DORMANT; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); } mrb_task_enable_irq(); return TRUE; @@ -286,11 +286,11 @@ wake_up_join_waiters(mrb_state *mrb, mrb_task *completed_task) while (curr != NULL) { mrb_task *next = curr->next; if (curr->reason == MRB_TASK_REASON_JOIN && curr->wait.join == completed_task) { - q_delete_task(mrb, curr); + mrb_task_q_delete(mrb, curr); curr->status = MRB_TASK_STATUS_READY; curr->reason = MRB_TASK_REASON_NONE; curr->wait.join = NULL; - q_insert_task(mrb, curr); + mrb_task_q_insert(mrb, curr); /* If a higher-priority waiter is resumed from task context, * request a context switch after leaving the critical section. */ if (mrb->c != mrb->root_c && !switching_) { @@ -310,9 +310,9 @@ static void task_change_state(mrb_state *mrb, mrb_task *t, uint8_t new_status) { mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); t->status = new_status; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); } @@ -369,9 +369,9 @@ execute_task(mrb_state *mrb, mrb_task *t) if (t->c.status == MRB_TASK_STOPPED) { switching_ = FALSE; mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); t->status = MRB_TASK_STATUS_DORMANT; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); /* Wake up tasks waiting on join */ @@ -421,10 +421,10 @@ mrb_tick(mrb_state *mrb) if (curr->reason == MRB_TASK_REASON_SLEEP) { if ((int32_t)(curr->wait.wakeup_tick - tick_) <= 0) { /* Time to wake up */ - q_delete_task(mrb, curr); + mrb_task_q_delete(mrb, curr); curr->status = MRB_TASK_STATUS_READY; curr->reason = MRB_TASK_REASON_NONE; - q_insert_task(mrb, curr); + mrb_task_q_insert(mrb, curr); switching_ = TRUE; } else if (curr->wait.wakeup_tick < next_wakeup) { @@ -551,7 +551,7 @@ sleep_us_impl(mrb_state *mrb, uint32_t usec) mrb_task_disable_irq(); /* Remove from ready queue */ - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); /* Move to waiting queue */ t->status = MRB_TASK_STATUS_WAITING; @@ -572,7 +572,7 @@ sleep_us_impl(mrb_state *mrb, uint32_t usec) (int32_t)(t->wait.wakeup_tick - wakeup_tick_) < 0) { wakeup_tick_ = t->wait.wakeup_tick; } - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); @@ -597,9 +597,9 @@ mrb_f_sleep(mrb_state *mrb, mrb_value self) mrb_task *t = q_ready_; if (t) { mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); t->status = MRB_TASK_STATUS_SUSPENDED; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); switching_ = TRUE; } @@ -666,7 +666,7 @@ task_create_common(mrb_state *mrb, const struct RProc *proc, task_init_context(mrb, t, proc); mrb_task_disable_irq(); - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); if (q_ready_ && q_ready_->status == MRB_TASK_STATUS_RUNNING) { @@ -1024,8 +1024,8 @@ mrb_task_set_priority(mrb_state *mrb, mrb_value self) /* Re-sort in queue if task is ready */ if (t->status == MRB_TASK_STATUS_READY || t->status == MRB_TASK_STATUS_RUNNING) { - q_delete_task(mrb, t); - q_insert_task(mrb, t); + mrb_task_q_delete(mrb, t); + mrb_task_q_insert(mrb, t); } mrb_task_enable_irq(); @@ -1100,11 +1100,11 @@ mrb_task_join(mrb_state *mrb, mrb_value self) /* Wait for task to complete */ mrb_task_disable_irq(); - q_delete_task(mrb, current); + mrb_task_q_delete(mrb, current); current->status = MRB_TASK_STATUS_WAITING; current->reason = MRB_TASK_REASON_JOIN; current->wait.join = t; - q_insert_task(mrb, current); + mrb_task_q_insert(mrb, current); mrb_task_enable_irq(); /* Trigger context switch */ @@ -1162,7 +1162,7 @@ mrb_execute_proc_synchronously(mrb_state *mrb, mrb_value proc_val, mrb_int argc, /* 3. Move task from DORMANT to READY */ mrb_task_disable_irq(); t->status = MRB_TASK_STATUS_READY; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); /* 4. Execute the task in a dedicated loop (no context switching) */ @@ -1186,7 +1186,7 @@ mrb_execute_proc_synchronously(mrb_state *mrb, mrb_value proc_val, mrb_int argc, /* 6. Free the temporary task's resources */ mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); mrb_task_enable_irq(); /* Prevent double-free: clear Data object's type before freeing task */ @@ -1362,10 +1362,10 @@ terminate_task_internal(mrb_state *mrb, mrb_task *t) if (t->status == MRB_TASK_STATUS_DORMANT) return; mrb_task_disable_irq(); - q_delete_task(mrb, t); + mrb_task_q_delete(mrb, t); t->status = MRB_TASK_STATUS_DORMANT; t->c.status = MRB_TASK_STOPPED; - q_insert_task(mrb, t); + mrb_task_q_insert(mrb, t); mrb_task_enable_irq(); wake_up_join_waiters(mrb, t); diff --git a/mrbgems/mruby-task/src/task_queue.c b/mrbgems/mruby-task/src/task_queue.c index bf1884c38..11175ff6f 100644 --- a/mrbgems/mruby-task/src/task_queue.c +++ b/mrbgems/mruby-task/src/task_queue.c @@ -36,11 +36,11 @@ queue_wake_one_waiter(mrb_state *mrb, mrb_task_queue *q) while (curr) { mrb_task *next = curr->next; if (curr->reason == MRB_TASK_REASON_QUEUE && curr->wait.queue == q) { - q_delete_task(mrb, curr); + mrb_task_q_delete(mrb, curr); curr->status = MRB_TASK_STATUS_READY; curr->reason = MRB_TASK_REASON_NONE; curr->wait.queue = NULL; - q_insert_task(mrb, curr); + mrb_task_q_insert(mrb, curr); switching_ = TRUE; break; } @@ -59,11 +59,11 @@ queue_wake_all_waiters(mrb_state *mrb, mrb_task_queue *q) while (curr) { mrb_task *next = curr->next; if (curr->reason == MRB_TASK_REASON_QUEUE && curr->wait.queue == q) { - q_delete_task(mrb, curr); + mrb_task_q_delete(mrb, curr); curr->status = MRB_TASK_STATUS_READY; curr->reason = MRB_TASK_REASON_NONE; curr->wait.queue = NULL; - q_insert_task(mrb, curr); + mrb_task_q_insert(mrb, curr); woke_any = TRUE; } curr = next; @@ -154,11 +154,11 @@ queue_pop_try(mrb_state *mrb, mrb_value self) /* Move current task to WAITING */ mrb_task *current = MRB2TASK(mrb); mrb_task_disable_irq(); - q_delete_task(mrb, current); + mrb_task_q_delete(mrb, current); current->status = MRB_TASK_STATUS_WAITING; current->reason = MRB_TASK_REASON_QUEUE; current->wait.queue = q; - q_insert_task(mrb, current); + mrb_task_q_insert(mrb, current); mrb_task_enable_irq(); switching_ = TRUE; From ccb62ceb57367fb4e119a6f32be6b15a4826520c Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Mon, 25 May 2026 23:36:42 +0900 Subject: [PATCH 28/51] mruby-string-ext: add String#scrub Replaces each maximal run of invalid UTF-8 bytes with a replacement string (U+FFFD by default), returning a valid UTF-8 copy. Mirrors CRuby's String#scrub (Feature #6752) -- the recovery counterpart to the existing String#valid_encoding? detection API. Validation matches utf8code() in src/string.c after the RFC 3629 / Unicode D93b conformance fixup (#2708): overlong encodings, UTF-16 surrogates, and codepoints above U+10FFFF are all treated as invalid. This is stricter than the existing mrb_utf8len()-based check used by valid_encoding?, so a string can report valid_encoding? = true and still get scrubbed; aligning valid_encoding? is a follow-up. The block form lives in mrblib on top of two C primitives -- __scrub and __scrub_chunks -- to avoid VM re-entry from C per CLAUDE.md. Non-String block return values are coerced via to_s (CRuby raises TypeError instead; the choice is locked in by test). Closes #6859. Co-authored-by: Claude --- mrbgems/mruby-string-ext/mrblib/string.rb | 30 +++++ mrbgems/mruby-string-ext/src/string.c | 157 ++++++++++++++++++++++ mrbgems/mruby-string-ext/test/string.rb | 42 ++++++ 3 files changed, 229 insertions(+) diff --git a/mrbgems/mruby-string-ext/mrblib/string.rb b/mrbgems/mruby-string-ext/mrblib/string.rb index 6c3c791f4..15d930a93 100644 --- a/mrbgems/mruby-string-ext/mrblib/string.rb +++ b/mrbgems/mruby-string-ext/mrblib/string.rb @@ -163,4 +163,34 @@ class String end self end + + ## + # call-seq: + # str.scrub -> new_str + # str.scrub(repl) -> new_str + # str.scrub {|bytes| block } -> new_str + # + # Returns a copy of +self+ with each maximal run of invalid UTF-8 bytes + # replaced by +repl+ (U+FFFD if +repl+ is omitted), or by the value + # returned from the block when one is given. The block receives the + # invalid bytes as a String. + # + # "abc\x80def".scrub #=> "abc\u{FFFD}def" + # "abc\x80def".scrub("?") #=> "abc?def" + # "\xE3\x81".scrub #=> "\u{FFFD}" + # "\x80\x81".scrub { |b| b.bytes.map { |c| "<%02X>" % c }.join } + # #=> "<80><81>" + def scrub(repl = nil, &block) + return __scrub(repl) unless block + chunks = __scrub_chunks + return chunks[0] if chunks.length == 1 + result = chunks[0].dup + i = 1 + while i < chunks.length + result << yield(chunks[i]).to_s + result << chunks[i + 1] if i + 1 < chunks.length + i += 2 + end + result + end end diff --git a/mrbgems/mruby-string-ext/src/string.c b/mrbgems/mruby-string-ext/src/string.c index d54edd8d7..4878db56e 100644 --- a/mrbgems/mruby-string-ext/src/string.c +++ b/mrbgems/mruby-string-ext/src/string.c @@ -1020,6 +1020,143 @@ str_ord(mrb_state* mrb, mrb_value str) return mrb_fixnum_value(c); } +/* Returns the byte length of a valid UTF-8 char starting at p, or -1 for + any invalid sequence (illegal lead byte, truncated tail, invalid + continuation byte, overlong encoding, UTF-16 surrogate, or codepoint + above U+10FFFF). Like utf8code() but reports rather than raises. */ +static mrb_int +str_scrub_char_len(const unsigned char *p, const unsigned char *e) +{ + if (p[0] < 0x80) return 1; + mrb_int len = mrb_utf8len_table[p[0]>>3]; + if (len < 2 || len > e - p) return -1; + for (mrb_int i = 1; i < len; i++) { + if ((p[i] & 0xc0) != 0x80) return -1; + } + mrb_int cp; + if (len == 2) { + cp = ((p[0] & 0x1f) << 6) | (p[1] & 0x3f); + if (cp < 0x80) return -1; + } + else if (len == 3) { + cp = ((p[0] & 0x0f) << 12) | ((p[1] & 0x3f) << 6) | (p[2] & 0x3f); + if (cp < 0x800) return -1; + if (cp >= 0xD800 && cp <= 0xDFFF) return -1; + } + else { /* len == 4 */ + cp = ((p[0] & 0x07) << 18) | ((p[1] & 0x3f) << 12) + | ((p[2] & 0x3f) << 6) | (p[3] & 0x3f); + if (cp < 0x10000 || cp > 0x10FFFF) return -1; + } + return len; +} + +static void +str_scrub_validate_replacement(mrb_state *mrb, mrb_value repl) +{ + const unsigned char *p = (const unsigned char*)RSTRING_PTR(repl); + const unsigned char *e = p + RSTRING_LEN(repl); + while (p < e) { + mrb_int len = str_scrub_char_len(p, e); + if (len < 0) { + mrb_raise(mrb, E_ARGUMENT_ERROR, "replacement must be valid UTF-8"); + } + p += len; + } +} + +/* Core of String#scrub for the no-block case. Returns a new string with + each maximal run of invalid UTF-8 bytes replaced by `repl` (or U+FFFD + if `repl` is nil). Already-valid strings are returned via mrb_str_dup. */ +static mrb_value +str_scrub_core(mrb_state *mrb, mrb_value self) +{ + mrb_value repl = mrb_nil_value(); + mrb_get_args(mrb, "|S!", &repl); + + const char *replace; + mrb_int replace_len; + if (mrb_nil_p(repl)) { + replace = "\xEF\xBF\xBD"; /* U+FFFD REPLACEMENT CHARACTER */ + replace_len = 3; + } + else { + str_scrub_validate_replacement(mrb, repl); + replace = RSTRING_PTR(repl); + replace_len = RSTRING_LEN(repl); + } + + struct RString *s = mrb_str_ptr(self); + if (RSTR_SINGLE_BYTE_P(s) || RSTR_BINARY_P(s)) { + return mrb_str_dup(mrb, self); + } + + const unsigned char *p = (const unsigned char*)RSTR_PTR(s); + const unsigned char *e = p + RSTR_LEN(s); + const unsigned char *valid_start = p; + const unsigned char *q = p; + mrb_value result = mrb_nil_value(); /* lazily allocated on first invalid byte */ + + while (q < e) { + mrb_int len = str_scrub_char_len(q, e); + if (len < 0) { + if (mrb_nil_p(result)) { + result = mrb_str_new(mrb, NULL, 0); + } + mrb_str_cat(mrb, result, (const char*)valid_start, q - valid_start); + mrb_str_cat(mrb, result, replace, replace_len); + q++; + while (q < e && str_scrub_char_len(q, e) < 0) q++; + valid_start = q; + } + else { + q += len; + } + } + + if (mrb_nil_p(result)) { + return mrb_str_dup(mrb, self); /* already valid */ + } + mrb_str_cat(mrb, result, (const char*)valid_start, q - valid_start); + return result; +} + +/* Splits self into alternating valid/invalid byte runs and returns them + as an Array of strings ([valid, invalid, valid, ...], odd length). + Used by the block form of String#scrub in mrblib; the block can then + map each invalid run to a replacement of its choosing without the C + side having to call back into the VM. */ +static mrb_value +str_scrub_chunks(mrb_state *mrb, mrb_value self) +{ + mrb_value ary = mrb_ary_new(mrb); + struct RString *s = mrb_str_ptr(self); + if (RSTR_SINGLE_BYTE_P(s) || RSTR_BINARY_P(s)) { + mrb_ary_push(mrb, ary, mrb_str_dup(mrb, self)); + return ary; + } + const unsigned char *p = (const unsigned char*)RSTR_PTR(s); + const unsigned char *e = p + RSTR_LEN(s); + const unsigned char *valid_start = p; + const unsigned char *q = p; + while (q < e) { + mrb_int len = str_scrub_char_len(q, e); + if (len < 0) { + mrb_ary_push(mrb, ary, mrb_str_new(mrb, (const char*)valid_start, q - valid_start)); + const unsigned char *invalid_start = q; + q++; + while (q < e && str_scrub_char_len(q, e) < 0) q++; + mrb_ary_push(mrb, ary, mrb_str_new(mrb, (const char*)invalid_start, q - invalid_start)); + valid_start = q; + } + else { + q += len; + } + } + mrb_ary_push(mrb, ary, mrb_str_new(mrb, (const char*)valid_start, q - valid_start)); + return ary; +} + /* Internal helper for String#codepoints - returns array of character codepoints */ static mrb_value str_codepoints(mrb_state *mrb, mrb_value str) @@ -1068,6 +1205,24 @@ str_codepoints(mrb_state *mrb, mrb_value self) } return result; } + +/* Non-UTF-8 builds: scrub is a no-op. The replacement arg is accepted + (and validated only as a String) for API parity with the UTF-8 build. */ +static mrb_value +str_scrub_core(mrb_state *mrb, mrb_value self) +{ + mrb_value repl = mrb_nil_value(); + mrb_get_args(mrb, "|S!", &repl); + return mrb_str_dup(mrb, self); +} + +static mrb_value +str_scrub_chunks(mrb_state *mrb, mrb_value self) +{ + mrb_value ary = mrb_ary_new(mrb); + mrb_ary_push(mrb, ary, mrb_str_dup(mrb, self)); + return ary; +} #endif static mrb_bool @@ -2282,6 +2437,8 @@ static const mrb_mt_entry string_ext_rom_entries[] = { MRB_MT_ENTRY(str_b, MRB_SYM(b), MRB_ARGS_NONE()), MRB_MT_ENTRY(str_lines, MRB_SYM(__lines), MRB_ARGS_NONE()), MRB_MT_ENTRY(str_codepoints, MRB_SYM(__codepoints), MRB_ARGS_NONE()), + MRB_MT_ENTRY(str_scrub_core, MRB_SYM(__scrub), MRB_ARGS_OPT(1)), + MRB_MT_ENTRY(str_scrub_chunks, MRB_SYM(__scrub_chunks), MRB_ARGS_NONE()), MRB_MT_ENTRY(str_lstrip, MRB_SYM(lstrip), MRB_ARGS_NONE()), MRB_MT_ENTRY(str_rstrip, MRB_SYM(rstrip), MRB_ARGS_NONE()), MRB_MT_ENTRY(str_strip, MRB_SYM(strip), MRB_ARGS_NONE()), diff --git a/mrbgems/mruby-string-ext/test/string.rb b/mrbgems/mruby-string-ext/test/string.rb index 335c4f81b..c76a7ef15 100644 --- a/mrbgems/mruby-string-ext/test/string.rb +++ b/mrbgems/mruby-string-ext/test/string.rb @@ -791,3 +791,45 @@ assert('String#-@') do a = -(a.freeze) assert_true(a.frozen?) end + +assert('String#scrub default replacement (U+FFFD)') do + assert_equal "\u{FFFD}", "\xE3\x81".scrub + assert_equal "abc\u{FFFD}def", "abc\x80def".scrub + assert_equal "\u{FFFD}", "\x80\x81\x82".scrub # run collapsed + assert_equal "", "".scrub + assert_equal "hello", "hello".scrub # already valid + assert_equal "あい", "あい".scrub # already valid multibyte +end + +assert('String#scrub rejects malformed sequences') do + # overlong, UTF-16 surrogate, codepoint above U+10FFFF + assert_equal "\u{FFFD}", "\xC0\xAF".scrub # overlong "/" + assert_equal "\u{FFFD}", "\xED\xA0\x80".scrub # surrogate U+D800 + assert_equal "\u{FFFD}", "\xF4\x90\x80\x80".scrub # > U+10FFFF +end + +assert('String#scrub with replacement string') do + assert_equal "abc?def", "abc\x80def".scrub("?") + assert_equal "abcdef", "abc\x80def".scrub("") + assert_equal "abcdef", "abc\x80def".scrub("") +end + +assert('String#scrub raises on invalid replacement') do + assert_raise(ArgumentError) { "abc\x80".scrub("\xFF") } +end + +assert('String#scrub with block') do + assert_equal "abc<80>def", + "abc\x80def".scrub { |b| "<" + b.bytes.first.to_s(16) + ">" } + # Block not called when string is already valid + called = false + "hello".scrub { |_| called = true; "X" } + assert_false called + # Multiple invalid runs each get their own block invocation + result = "a\x80b\x81c".scrub { |b| "[#{b.bytes.first}]" } + assert_equal "a[128]b[129]c", result + # Non-String block return values are coerced via to_s (mruby leniency; + # CRuby raises TypeError instead). Locking this in so the choice is + # explicit and doesn't drift accidentally. + assert_equal "abc42def", "abc\x80def".scrub { 42 } +end From 012691279d213f249c167fd5af5850ac5e91a54e Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Tue, 26 May 2026 23:10:45 +0900 Subject: [PATCH 29/51] mruby-string-ext: skip scrub UTF-8 assertions on non-UTF-8 builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MRB_UTF8_STRING is undefined, String#scrub is a no-op that returns the receiver as-is (the #else branch added in ccb62ceb57). The unit tests assumed UTF-8 semantics unconditionally, so a build of just mruby-string-ext without UTF-8 strings failed all five scrub tests. Guard the UTF-8-dependent assertions with `skip unless "あ".length == 1`, and add a paired test that asserts the no-op behaviour on the non-UTF-8 build (skipped on UTF-8 builds). Verified against the build config from the report: - UTF-8 (host-debug): 5 tests pass, 1 skip - non-UTF-8 (noutf8): 5 skip, 1 test pass Closes #6860. Co-authored-by: Claude --- mrbgems/mruby-string-ext/test/string.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mrbgems/mruby-string-ext/test/string.rb b/mrbgems/mruby-string-ext/test/string.rb index c76a7ef15..c0fd381db 100644 --- a/mrbgems/mruby-string-ext/test/string.rb +++ b/mrbgems/mruby-string-ext/test/string.rb @@ -793,6 +793,9 @@ assert('String#-@') do end assert('String#scrub default replacement (U+FFFD)') do + # scrub has UTF-8 semantics; on builds without MRB_UTF8_STRING it + # degrades to a no-op (verified separately below). + skip unless "あ".length == 1 assert_equal "\u{FFFD}", "\xE3\x81".scrub assert_equal "abc\u{FFFD}def", "abc\x80def".scrub assert_equal "\u{FFFD}", "\x80\x81\x82".scrub # run collapsed @@ -802,6 +805,7 @@ assert('String#scrub default replacement (U+FFFD)') do end assert('String#scrub rejects malformed sequences') do + skip unless "あ".length == 1 # overlong, UTF-16 surrogate, codepoint above U+10FFFF assert_equal "\u{FFFD}", "\xC0\xAF".scrub # overlong "/" assert_equal "\u{FFFD}", "\xED\xA0\x80".scrub # surrogate U+D800 @@ -809,16 +813,19 @@ assert('String#scrub rejects malformed sequences') do end assert('String#scrub with replacement string') do + skip unless "あ".length == 1 assert_equal "abc?def", "abc\x80def".scrub("?") assert_equal "abcdef", "abc\x80def".scrub("") assert_equal "abcdef", "abc\x80def".scrub("") end assert('String#scrub raises on invalid replacement') do + skip unless "あ".length == 1 assert_raise(ArgumentError) { "abc\x80".scrub("\xFF") } end assert('String#scrub with block') do + skip unless "あ".length == 1 assert_equal "abc<80>def", "abc\x80def".scrub { |b| "<" + b.bytes.first.to_s(16) + ">" } # Block not called when string is already valid @@ -833,3 +840,11 @@ assert('String#scrub with block') do # explicit and doesn't drift accidentally. assert_equal "abc42def", "abc\x80def".scrub { 42 } end + +assert('String#scrub is a no-op without MRB_UTF8_STRING') do + skip if "あ".length == 1 + # Method is still defined and returns a (string-equal) copy. + assert_equal "abc\x80def", "abc\x80def".scrub + assert_equal "abc\x80def", "abc\x80def".scrub("?") + assert_equal "abc\x80def", "abc\x80def".scrub { |_| "?" } +end From f5a5bfcbf61166c3d574a67612f318e356dfa8f6 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Wed, 27 May 2026 08:49:07 +0900 Subject: [PATCH 30/51] limitations.md: replace binding note with general mrbgem pattern The Kernel#binding entry was treating "feature provided by mrbgem" as a per-method limitation, which does not scale. mruby implements much of Ruby's standard surface area through mrbgems, so listing each one would grow without bound. Replace it with a single top-level note explaining the general pattern: which features are available depends on the linked gems, and a NoMethodError on a familiar Ruby method usually points to a missing gem rather than a true mruby gap. Refs #6861. Co-authored-by: Claude --- doc/limitations.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/doc/limitations.md b/doc/limitations.md index 4d8fc4708..b0908583a 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -15,6 +15,23 @@ This document is collecting these limitations. This document does not contain a complete list of limitations. Please help to improve it by submitting your findings. +## Features provided by mrbgems + +Many Ruby features that CRuby builds into its core are provided by +mrbgems in mruby. Which features are actually available depends on +which mrbgems are linked into the build. The `default.gembox` and +`stdlib.gembox` cover the common cases, but a minimal build can omit +familiar features such as `Kernel#binding` (provided by +`mruby-binding`), `Kernel#catch`/`throw` (by `mruby-catch`), +`Enumerable` extensions, `Comparable`, IO, regular expressions, and +many more. + +This is by design rather than a limitation per se. When porting Ruby +code to mruby, a `NoMethodError` or `NameError` often means "the gem +providing this feature is not linked in" rather than "mruby does not +support it." Adding the relevant gem to the build configuration is +usually enough. + ## `Kernel.raise` in rescue clause `Kernel.raise` without arguments does not raise the current exception within @@ -133,12 +150,6 @@ The re-defined `+` operator does not accept any arguments. `'ab'` Behavior of the operator wasn't changed. -## `Kernel#binding` is not supported without mruby-binding gem - -`Kernel#binding` method requires the `mruby-binding` gem (included -in the `metaprog` gembox). Without this gem, `binding` is not -available. - ## `nil?` redefinition in conditional expressions Redefinition of `nil?` is ignored in conditional expressions. From d1289ac8f97160a5c4e6cc20b8773c6064d079af Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 07:27:37 +0900 Subject: [PATCH 31/51] vm.c: defer task switches during gc.iterating RETURN_IF_TASK_STOPPED used to bail out of mrb_vm_exec as soon as task.switching was set, even when the exec was called re-entrantly from inside a heap-walk callback (e.g. ObjectSpace.each_object via mrb_yield). Bailing in that situation only unwinds the inner exec, leaving the outer C iteration to keep calling the callback. The call-info stack drifts on each subsequent mrb_yield, and the program either trips the cibase assertion in mrb_vm_run or crashes in __longjmp. Hold off the switch while mrb->gc.iterating is set so the heap walk finishes intact; the switch then fires at the next OP boundary once the walk releases the flag. MRB_TASK_STOPPED is intentionally not deferred, since exiting promptly is still correct when the task itself is going away. Fixes #6862. Co-authored-by: Claude --- src/vm.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vm.c b/src/vm.c index b1de67637..fae3b929c 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1588,8 +1588,16 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci #define CALL_CODE_HOOKS() do { insn = BYTECODE_DECODER(*ci->pc); CODE_FETCH_HOOK(mrb, irep, ci->pc, regs); } while (0) #ifdef MRB_USE_TASK_SCHEDULER +/* Defer task switches while a C-level ObjectSpace walk holds gc.iterating + true. The walk runs callbacks (which may call back into mrb_vm_exec via + mrb_yield); returning early from an inner exec while the outer C + iteration is still active drifts the call-info stack and eventually + crashes (issue #6862). Switches resume at the next OP boundary after + the walk releases gc.iterating. A pending MRB_TASK_STOPPED is not + deferred -- the task is going away. */ #define RETURN_IF_TASK_STOPPED(mrb) do { \ - if ((mrb)->task.switching || (mrb)->c->status == MRB_TASK_STOPPED) \ + if (((mrb)->task.switching && !(mrb)->gc.iterating) || \ + (mrb)->c->status == MRB_TASK_STOPPED) \ return mrb_nil_value(); \ } while (0) #define TASK_STOP(mrb) do { \ From 819156e67854a3501567a2f7e06fc958d89e9666 Mon Sep 17 00:00:00 2001 From: 0x1eef <0x1eef@hardenedbsd.org> Date: Wed, 27 May 2026 23:00:45 -0300 Subject: [PATCH 32/51] task: return `nil` when given a nested call to `Task.run` When you try to start an event loop inside an event loop, the mruby process will SIGSEGV: ```ruby Task.new { Task.run } Task.run ``` This change turns the second call to `Task.run` into a noop that returns nil instead. Fix #6865 --- include/mruby.h | 1 + mrbgems/mruby-task/src/task.c | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/include/mruby.h b/include/mruby.h index db89b6236..7bbfb9634 100644 --- a/include/mruby.h +++ b/include/mruby.h @@ -274,6 +274,7 @@ typedef struct mrb_task_state { volatile mrb_bool switching; /* Context switch pending flag */ struct mrb_task *main_task; /* Main task wrapper for root context */ uint8_t scheduler_lock; /* Lock counter for synchronous execution */ + mrb_bool loop_running; /* Active mrb_task_run loop flag */ } mrb_task_state; #endif diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 70f9e6233..9662ffdfc 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -445,6 +445,11 @@ mrb_task_run(mrb_state *mrb) { mrb_task *t; + if (mrb->task.loop_running) { + return mrb_nil_value(); + } + mrb->task.loop_running = TRUE; + while (1) { t = q_ready_; @@ -481,6 +486,7 @@ mrb_task_run(mrb_state *mrb) } } + mrb->task.loop_running = FALSE; return mrb_nil_value(); } @@ -1505,6 +1511,7 @@ mrb_mruby_task_gem_init(mrb_state *mrb) /* Initialize main task to NULL and scheduler_lock to 0 */ mrb->task.main_task = NULL; mrb->task.scheduler_lock = 0; + mrb->task.loop_running = FALSE; task_class = mrb_define_class_id(mrb, MRB_SYM(Task), mrb->object_class); MRB_SET_INSTANCE_TT(task_class, MRB_TT_DATA); From ee82a7fcc66f05af310780cfe812272780bc32ce Mon Sep 17 00:00:00 2001 From: 0x1eef <0x1eef@hardenedbsd.org> Date: Wed, 27 May 2026 23:19:43 -0300 Subject: [PATCH 33/51] fix: wrap mrb_task_run in MRB_TRY/MRB_CATCH --- mrbgems/mruby-task/src/task.c | 69 +++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 9662ffdfc..695f6f81c 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -443,6 +444,8 @@ mrb_tick(mrb_state *mrb) MRB_API mrb_value mrb_task_run(mrb_state *mrb) { + struct mrb_jmpbuf *prev_jmp; + struct mrb_jmpbuf c_jmp; mrb_task *t; if (mrb->task.loop_running) { @@ -450,41 +453,51 @@ mrb_task_run(mrb_state *mrb) } mrb->task.loop_running = TRUE; - while (1) { - t = q_ready_; + prev_jmp = mrb->jmp; + MRB_TRY(&c_jmp) { + mrb->jmp = &c_jmp; - /* No task ready - check if all tasks are done */ - if (!t) { - mrb_task_disable_irq(); - mrb_bool exiting = !q_ready_ && !q_waiting_ && !q_suspended_; - mrb_task_enable_irq(); - if (exiting) { - /* All tasks are dormant - scheduler done */ - break; + while (1) { + t = q_ready_; + + /* No task ready - check if all tasks are done */ + if (!t) { + mrb_task_disable_irq(); + mrb_bool exiting = !q_ready_ && !q_waiting_ && !q_suspended_; + mrb_task_enable_irq(); + if (exiting) { + /* All tasks are dormant - scheduler done */ + break; + } + /* If there are tasks waiting or suspended, idle */ + mrb_hal_task_idle_cpu(mrb); + continue; } - /* If there are tasks waiting or suspended, idle */ - mrb_hal_task_idle_cpu(mrb); - continue; - } - /* Safety check - don't execute terminated tasks */ - if (task_cleanup_if_stopped(mrb, t)) { - continue; - } + /* Safety check - don't execute terminated tasks */ + if (task_cleanup_if_stopped(mrb, t)) { + continue; + } - /* Execute task using core logic */ - execute_task(mrb, t); + /* Execute task using core logic */ + execute_task(mrb, t); - /* Move to end of ready queue if still running (round-robin) */ - if (t->status == MRB_TASK_STATUS_READY) { - task_change_state(mrb, t, MRB_TASK_STATUS_READY); - } + /* Move to end of ready queue if still running (round-robin) */ + if (t->status == MRB_TASK_STATUS_READY) { + task_change_state(mrb, t, MRB_TASK_STATUS_READY); + } - /* Run incremental GC if active */ - if (mrb->gc.state != MRB_GC_STATE_ROOT) { - mrb_incremental_gc(mrb); + /* Run incremental GC if active */ + if (mrb->gc.state != MRB_GC_STATE_ROOT) { + mrb_incremental_gc(mrb); + } } - } + mrb->jmp = prev_jmp; + } MRB_CATCH(&c_jmp) { + mrb->task.loop_running = FALSE; + mrb->jmp = prev_jmp; + MRB_THROW(prev_jmp); + } MRB_END_EXC(&c_jmp); mrb->task.loop_running = FALSE; return mrb_nil_value(); From 4bdde3a0a83ade8250bd8d4c743a4e4a11c9b012 Mon Sep 17 00:00:00 2001 From: 0x1eef <0x1eef@hardenedbsd.org> Date: Wed, 27 May 2026 23:27:51 -0300 Subject: [PATCH 34/51] task: add test --- mrbgems/mruby-task/test/task.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mrbgems/mruby-task/test/task.rb b/mrbgems/mruby-task/test/task.rb index e93c525af..048bb8136 100644 --- a/mrbgems/mruby-task/test/task.rb +++ b/mrbgems/mruby-task/test/task.rb @@ -183,3 +183,10 @@ assert("Task.new with block doesn't execute immediately") do # Block should not execute until scheduler runs assert_false executed end + +assert("Task.run inside Task.run is a noop") do + assert_nothing_raised do + Task.new { Task.run } + Task.run + end +end From dfc542dbc5054737ac9c8459b9843fb0a442b7f0 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 12:02:07 +0900 Subject: [PATCH 35/51] mruby-task: terminate suspended task in suspend test The "Task#suspend doesn't raise" test left its task parked in q_suspended_. A later test ("Task.run inside Task.run is a noop") calls Task.run, and the scheduler will not exit its loop while any task sits in the suspended (or waiting) queue, so the whole run hung. Terminate the task at the end of the suspend test so it does not leak into the shared scheduler state the next test depends on. This fixes the hang at its source rather than scrubbing leaked tasks from the consumer side. Refs #6866, #6867. Co-authored-by: Claude --- mrbgems/mruby-task/test/task.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mrbgems/mruby-task/test/task.rb b/mrbgems/mruby-task/test/task.rb index 048bb8136..678b136ab 100644 --- a/mrbgems/mruby-task/test/task.rb +++ b/mrbgems/mruby-task/test/task.rb @@ -81,6 +81,10 @@ end assert("Task#suspend doesn't raise") do task = Task.new { } assert_nothing_raised { task.suspend } + # Clean up: a suspended task left in q_suspended_ keeps a later + # Task.run from terminating (the scheduler idles waiting on it + # instead of exiting). + task.terminate end assert("Task#resume doesn't raise") do From c09196ca364558ef4fa7f99ee9e0566cbd8cfc35 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 12:10:08 +0900 Subject: [PATCH 36/51] mruby-task: switch mrb_task_run to mrb_protect_error mruby/throw.h is documented as a core-internal header that should not be included from mrbgems or user code, and under MRB_USE_CXX_EXCEPTION or MRB_USE_CXX_ABI the MRB_TRY/MRB_CATCH macros expand to C++ exception syntax that does not compile in a C source file. The wrapping added in #6866 (commit ee82a7fcc6) accidentally tripped that constraint. Drop the throw.h include and use mrb_protect_error() from mruby/error.h instead. The helper takes a body function plus userdata, runs it under its own jmpbuf, and reports whether an exception was caught. We re-raise via mrb_exc_raise so the visible behavior matches the previous code: loop_running is cleared on both success and exception, and an exception propagates back out. Refs #6866. Co-authored-by: Claude --- mrbgems/mruby-task/src/task.c | 105 +++++++++++++++++----------------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 695f6f81c..a94241534 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -440,67 +439,69 @@ mrb_tick(mrb_state *mrb) } } +/* Body of the main scheduler loop. Wrapped by mrb_task_run() under + mrb_protect_error so an exception raised from a task body unwinds + cleanly without leaving `loop_running` set. */ +static mrb_value +task_run_body(mrb_state *mrb, void *ud) +{ + mrb_task *t; + (void)ud; + + while (1) { + t = q_ready_; + + /* No task ready - check if all tasks are done */ + if (!t) { + mrb_task_disable_irq(); + mrb_bool exiting = !q_ready_ && !q_waiting_ && !q_suspended_; + mrb_task_enable_irq(); + if (exiting) { + /* All tasks are dormant - scheduler done */ + break; + } + /* If there are tasks waiting or suspended, idle */ + mrb_hal_task_idle_cpu(mrb); + continue; + } + + /* Safety check - don't execute terminated tasks */ + if (task_cleanup_if_stopped(mrb, t)) { + continue; + } + + /* Execute task using core logic */ + execute_task(mrb, t); + + /* Move to end of ready queue if still running (round-robin) */ + if (t->status == MRB_TASK_STATUS_READY) { + task_change_state(mrb, t, MRB_TASK_STATUS_READY); + } + + /* Run incremental GC if active */ + if (mrb->gc.state != MRB_GC_STATE_ROOT) { + mrb_incremental_gc(mrb); + } + } + return mrb_nil_value(); +} + /* Main scheduler loop */ MRB_API mrb_value mrb_task_run(mrb_state *mrb) { - struct mrb_jmpbuf *prev_jmp; - struct mrb_jmpbuf c_jmp; - mrb_task *t; - if (mrb->task.loop_running) { return mrb_nil_value(); } mrb->task.loop_running = TRUE; - prev_jmp = mrb->jmp; - MRB_TRY(&c_jmp) { - mrb->jmp = &c_jmp; - - while (1) { - t = q_ready_; - - /* No task ready - check if all tasks are done */ - if (!t) { - mrb_task_disable_irq(); - mrb_bool exiting = !q_ready_ && !q_waiting_ && !q_suspended_; - mrb_task_enable_irq(); - if (exiting) { - /* All tasks are dormant - scheduler done */ - break; - } - /* If there are tasks waiting or suspended, idle */ - mrb_hal_task_idle_cpu(mrb); - continue; - } - - /* Safety check - don't execute terminated tasks */ - if (task_cleanup_if_stopped(mrb, t)) { - continue; - } - - /* Execute task using core logic */ - execute_task(mrb, t); - - /* Move to end of ready queue if still running (round-robin) */ - if (t->status == MRB_TASK_STATUS_READY) { - task_change_state(mrb, t, MRB_TASK_STATUS_READY); - } - - /* Run incremental GC if active */ - if (mrb->gc.state != MRB_GC_STATE_ROOT) { - mrb_incremental_gc(mrb); - } - } - mrb->jmp = prev_jmp; - } MRB_CATCH(&c_jmp) { - mrb->task.loop_running = FALSE; - mrb->jmp = prev_jmp; - MRB_THROW(prev_jmp); - } MRB_END_EXC(&c_jmp); - + mrb_bool error = FALSE; + mrb_value result = mrb_protect_error(mrb, task_run_body, NULL, &error); mrb->task.loop_running = FALSE; - return mrb_nil_value(); + if (error) { + mrb_exc_raise(mrb, result); + } + return result; } /* Single-step task execution for WASM event loop integration */ From dc671f007abf4b931eeec869d0f16cddd16d9df9 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 13:19:10 +0900 Subject: [PATCH 37/51] vm.c: restore mrb->jmp on early return for task switch mrb_vm_exec sets mrb->jmp to its own stack-local c_jmp on entry and restores the caller's prev_jmp on every normal return path. The early return added for task switching (RETURN_IF_TASK_STOPPED) skipped that restore, so after a task was preempted via Task.pass the dangling mrb->jmp pointed into mrb_vm_exec's freed stack frame. A subsequent raise then longjmp'd into that freed frame and crashed. Restore prev_jmp in the early-return path, matching the normal returns. Task.new { Task.pass } Task.pass raise "boom" # SIGSEGV before this change Fixes #6863. Refs #6864. Co-authored-by: Claude --- src/vm.c | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/vm.c b/src/vm.c index fae3b929c..10efd5b70 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1594,11 +1594,21 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci iteration is still active drifts the call-info stack and eventually crashes (issue #6862). Switches resume at the next OP boundary after the walk releases gc.iterating. A pending MRB_TASK_STOPPED is not - deferred -- the task is going away. */ + deferred, since the task is going away. + + mrb->jmp is restored to prev_jmp before returning, exactly as the + normal return paths below do. mrb_vm_exec set mrb->jmp to its own + stack-local c_jmp on entry; leaving it dangling after this early return + means a later raise longjmps into a freed frame (issue #6863). + + This macro must only be expanded where prev_jmp is in scope, i.e. + inside mrb_vm_exec (via NEXT / END_DISPATCH). */ #define RETURN_IF_TASK_STOPPED(mrb) do { \ if (((mrb)->task.switching && !(mrb)->gc.iterating) || \ - (mrb)->c->status == MRB_TASK_STOPPED) \ + (mrb)->c->status == MRB_TASK_STOPPED) { \ + (mrb)->jmp = prev_jmp; \ return mrb_nil_value(); \ + } \ } while (0) #define TASK_STOP(mrb) do { \ if (mrb->c->status != MRB_TASK_STOPPED) \ From d2a1c43a5f766a18dcb609e80839483caf8fabad Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 15:36:21 +0900 Subject: [PATCH 38/51] vm.c: defer task switch across C call boundary A task switch (early return from mrb_vm_exec) is unsafe when execution has re-entered the VM from C (mrb_funcall, mrb_yield, mrb_vm_run). The C stack frame between the scheduler's mrb_vm_exec and the current frame cannot be suspended, and returning early from the inner mrb_vm_exec leaves the call-info stack drifted, so the enclosing mrb_vm_run trips its `c->ci == c->cibase || ...` assertion (or corrupts state in a non-debug build). This happens when a block yielded from a C function wakes a task, for example Task::Queue#push from inside a block passed to a C method via mrb_yield_argv. Defer the switch via task_across_c_boundary, which walks the call-info stack for a C frame (cci > 0), mirroring the cooperative guard in Task.pass. The switch resumes once execution unwinds back to a frame with no C boundary. The gc.iterating short-circuit from #6862 is kept as a cheap pre-check. Fixes #6868. Refs #6864. Co-authored-by: Claude --- src/vm.c | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/vm.c b/src/vm.c index 10efd5b70..bb0c877f1 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1588,13 +1588,33 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci #define CALL_CODE_HOOKS() do { insn = BYTECODE_DECODER(*ci->pc); CODE_FETCH_HOOK(mrb, irep, ci->pc, regs); } while (0) #ifdef MRB_USE_TASK_SCHEDULER +/* TRUE when the current context is executing across a C call boundary, i.e. + a C function on the stack re-entered the VM (mrb_funcall / mrb_yield / + mrb_vm_run). A task cannot be suspended at such a point: the C stack + frame between the scheduler's mrb_vm_exec and the current frame cannot + be saved or restored, and returning early from the inner mrb_vm_exec + would leave the call-info stack drifted, tripping the assertion in + mrb_vm_run (issues #6864, #6868). The scheduler defers the switch until + execution unwinds back to a frame with no C boundary. This mirrors the + cooperative guard in Task.pass, which raises rather than defers. + cibase is excluded: it is the entry frame of this mrb_vm_exec. */ +static mrb_bool +task_across_c_boundary(mrb_state *mrb) +{ + for (mrb_callinfo *ci = mrb->c->ci; ci > mrb->c->cibase; ci--) { + if (ci->cci > 0) return TRUE; + } + return FALSE; +} + /* Defer task switches while a C-level ObjectSpace walk holds gc.iterating true. The walk runs callbacks (which may call back into mrb_vm_exec via mrb_yield); returning early from an inner exec while the outer C iteration is still active drifts the call-info stack and eventually crashes (issue #6862). Switches resume at the next OP boundary after - the walk releases gc.iterating. A pending MRB_TASK_STOPPED is not - deferred, since the task is going away. + the walk releases gc.iterating. A pending switch is also deferred while + executing across a C call boundary (see task_across_c_boundary). A + pending MRB_TASK_STOPPED is not deferred, since the task is going away. mrb->jmp is restored to prev_jmp before returning, exactly as the normal return paths below do. mrb_vm_exec set mrb->jmp to its own @@ -1604,7 +1624,8 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci This macro must only be expanded where prev_jmp is in scope, i.e. inside mrb_vm_exec (via NEXT / END_DISPATCH). */ #define RETURN_IF_TASK_STOPPED(mrb) do { \ - if (((mrb)->task.switching && !(mrb)->gc.iterating) || \ + if (((mrb)->task.switching && !(mrb)->gc.iterating && \ + !task_across_c_boundary(mrb)) || \ (mrb)->c->status == MRB_TASK_STOPPED) { \ (mrb)->jmp = prev_jmp; \ return mrb_nil_value(); \ From 030a08092aa9121da14b538512094088841b2ac0 Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Thu, 28 May 2026 18:13:18 +0900 Subject: [PATCH 39/51] Separate the union of timeslice and result in struct mrb_task Bug scenario: * VM returns an Exception `t->state.result = mrb_vm_exec(...);` * Despite task is still MRB_TASK_STATUS_RUNNING, IRQ triggered by chance and `mrb_tick()` executes `t->state.timeslice--;` * But the same memory area already holds the `result`, `timeslice--` reduces `result.value.p`'s top byte The fix is to separate `timeslice` and `result` into different fields. I have considered improving critical sections, but I ended up with this patch because I believe it is widely effective and less error-prone. --- mrbgems/mruby-task/include/task.h | 8 ++------ mrbgems/mruby-task/src/task.c | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/mrbgems/mruby-task/include/task.h b/mrbgems/mruby-task/include/task.h index 0719fe018..d9c458629 100644 --- a/mrbgems/mruby-task/include/task.h +++ b/mrbgems/mruby-task/include/task.h @@ -41,7 +41,6 @@ struct mrb_task_queue; * - Removed started flag (inferred from c.status): 1 byte * - Unified wakeup_tick/join/mutex into single union: 4 bytes * - Removed redundant proc field (stored in c.ci->proc): 8 bytes - * - Unified timeslice/result into state union: ~4 bytes * Total savings: ~18 bytes per task (14% reduction) */ typedef struct mrb_task { @@ -61,11 +60,8 @@ typedef struct mrb_task { mrb_value self; /* Ruby Task object reference */ - /* State-specific data - mutually exclusive based on status */ - union { - volatile uint8_t timeslice; /* Remaining ticks (RUNNING only) */ - mrb_value result; /* Task return value (DORMANT only) */ - } state; + volatile uint8_t timeslice; /* Remaining ticks while RUNNING */ + mrb_value result; /* Task return value */ struct mrb_context c; /* Execution context (stack, callinfo, etc) */ } mrb_task; diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index a94241534..2b91f5c65 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -114,7 +114,7 @@ mrb_task_mark_all(mrb_state *mrb) /* Mark task-specific values */ mrb_gc_mark_value(mrb, t->self); if (t->status == MRB_TASK_STATUS_DORMANT) { - mrb_gc_mark_value(mrb, t->state.result); + mrb_gc_mark_value(mrb, t->result); } mrb_gc_mark_value(mrb, t->name); @@ -326,7 +326,7 @@ execute_task(mrb_state *mrb, mrb_task *t) /* Set task as running */ t->status = MRB_TASK_STATUS_RUNNING; - t->state.timeslice = MRB_TIMESLICE_TICK_COUNT; + t->timeslice = MRB_TIMESLICE_TICK_COUNT; /* Switch to task context */ prev_c = mrb->c; @@ -351,7 +351,7 @@ execute_task(mrb_state *mrb, mrb_task *t) t->c.vmexec = TRUE; /* Execute task - PC is saved in ci->pc from previous run */ - t->state.result = mrb_vm_exec(mrb, proc, pc); + t->result = mrb_vm_exec(mrb, proc, pc); /* Clear vmexec flag */ t->c.vmexec = FALSE; @@ -394,9 +394,9 @@ mrb_tick(mrb_state *mrb) /* Decrease timeslice for running task */ t = q_ready_; - if (t && t->status == MRB_TASK_STATUS_RUNNING && t->state.timeslice > 0) { - t->state.timeslice--; - if (t->state.timeslice == 0) { + if (t && t->status == MRB_TASK_STATUS_RUNNING && t->timeslice > 0) { + t->timeslice--; + if (t->timeslice == 0) { switching_ = TRUE; /* Trigger context switch */ } } @@ -1115,7 +1115,7 @@ mrb_task_join(mrb_state *mrb, mrb_value self) /* If task is already dormant, return immediately */ if (t->status == MRB_TASK_STATUS_DORMANT) { - return t->state.result; + return t->result; } /* Wait for task to complete */ @@ -1130,7 +1130,7 @@ mrb_task_join(mrb_state *mrb, mrb_value self) /* Trigger context switch */ switching_ = TRUE; - return t->state.result; + return t->result; } /* @@ -1190,16 +1190,16 @@ mrb_execute_proc_synchronously(mrb_state *mrb, mrb_value proc_val, mrb_int argc, mrb->c = &t->c; while (t->c.status != MRB_TASK_STOPPED) { - t->state.result = mrb_vm_exec(mrb, mrb->c->ci->proc, mrb->c->ci->pc); + t->result = mrb_vm_exec(mrb, mrb->c->ci->proc, mrb->c->ci->pc); } /* If there's an unhandled exception after VM stops, save it as result */ if (mrb->exc) { - t->state.result = mrb_obj_value(mrb->exc); + t->result = mrb_obj_value(mrb->exc); } /* 5. Get result and clean up */ - mrb_value result = t->state.result; + mrb_value result = t->result; if (mrb_obj_ptr(result) == mrb->exc) { mrb->exc = NULL; /* Clear exception */ } @@ -1437,7 +1437,7 @@ mrb_task_value(mrb_state *mrb, mrb_value task) mrb_task *t = (mrb_task*)mrb_data_check_get_ptr(mrb, task, &mrb_task_type); if (!t) return mrb_nil_value(); - return t->state.result; + return t->result; } /* From 5e669560ebee9169f1b1d3ce2c7fcc50bbb92cc6 Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Thu, 28 May 2026 19:07:20 +0900 Subject: [PATCH 40/51] Amend field position to pack effectively --- mrbgems/mruby-task/include/task.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mrbgems/mruby-task/include/task.h b/mrbgems/mruby-task/include/task.h index d9c458629..6a464eb9c 100644 --- a/mrbgems/mruby-task/include/task.h +++ b/mrbgems/mruby-task/include/task.h @@ -48,6 +48,7 @@ typedef struct mrb_task { uint8_t priority; /* Priority (0-255, 0=highest) */ uint8_t status; /* Current status (TASKSTATUS enum) */ uint8_t reason; /* Wait reason (TASKREASON enum) */ + volatile uint8_t timeslice; /* Remaining ticks while RUNNING */ mrb_value name; /* Optional task name */ /* Wait-specific data - mutually exclusive based on reason field */ @@ -60,7 +61,6 @@ typedef struct mrb_task { mrb_value self; /* Ruby Task object reference */ - volatile uint8_t timeslice; /* Remaining ticks while RUNNING */ mrb_value result; /* Task return value */ struct mrb_context c; /* Execution context (stack, callinfo, etc) */ From 564726c44f7d4df3efeff749a18f62e444967ee2 Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Thu, 28 May 2026 19:16:04 +0900 Subject: [PATCH 41/51] t->result now can be marked unconditionally --- mrbgems/mruby-task/src/task.c | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 2b91f5c65..fbca3ceec 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -113,9 +113,7 @@ mrb_task_mark_all(mrb_state *mrb) /* Mark task-specific values */ mrb_gc_mark_value(mrb, t->self); - if (t->status == MRB_TASK_STATUS_DORMANT) { - mrb_gc_mark_value(mrb, t->result); - } + mrb_gc_mark_value(mrb, t->result); mrb_gc_mark_value(mrb, t->name); t = t->next; From e0d6d39ce98e64c167b0da3f35ed1b07a34a3978 Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Thu, 28 May 2026 19:18:17 +0900 Subject: [PATCH 42/51] Write validate flag (status) after setting timeslice `mrb_tick()` observes `status == RUNNING` before touching `timeslice`, so initializing `timeslice` first avoids exposing a partially initialized running state --- mrbgems/mruby-task/src/task.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index fbca3ceec..5aa47c1ef 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -323,8 +323,8 @@ execute_task(mrb_state *mrb, mrb_task *t) uint8_t prev_cci; /* Set task as running */ - t->status = MRB_TASK_STATUS_RUNNING; t->timeslice = MRB_TIMESLICE_TICK_COUNT; + t->status = MRB_TASK_STATUS_RUNNING; /* Switch to task context */ prev_c = mrb->c; From f1232334c0571bd47f393ba3cba20eb1703eeea6 Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Thu, 28 May 2026 22:50:02 +0900 Subject: [PATCH 43/51] Fix execute_task() so unhandled task exceptions become task results This patch fixes a bug that c09196c introduced. ## Background `mrb_task_run()` has two usage patterns: 1. Called directly from `main()` as the top-level scheduler (PicoRuby and R2P2). There is no surrounding C exception handler, so mrb->jmp is NULL on entry 2. Called from Ruby code via Task.run, bootstrapped on top of mruby's regular call chain. mrb->jmp is non-NULL Historically, an unhandled exception raised inside a task body was turned into the task's result value by `mrb_vm_exec()`: the L_RAISE path walked callinfo down to cibase, ran `fiber_terminate()`, and - because c->vmexec was TRUE and prev_jmp was NULL in pattern 1 - took `return mrb_obj_value(mrb->exc)`. That value landed in t->result and could be read back through `mrb_task_value()` / `join()`. ## What c09196c broke It consider only pattern 2 and wrapped `mrb_task_run()` in a protect frame (MRB_TRY / mrb_protect_error) to guarantee that loop_running is cleared on exception. As a side effect, mrb->jmp is now always non-NULL while a task body is executing, so the L_RAISE path takes `MRB_THROW(prev_jmp)` instead of returning the exception value. In pattern 2 this merely changed the semantics (exceptions started propagating out of `Task.run` instead of being stored as task results). In pattern 1 it was FATAL: the throw unwound to mrb_task_run's catch handler, which called `mrb_exc_raise()` to re-propagate, and with no outer jmpbuf this aborted the process. PicoRuby/R2P2 could no longer retrieve task exceptions via `mrb_task_value()`. ## Fix Restore the "task exception becomes task result" contract uniformly for both patterns, independent of mrb->jmp: * Add `mrb_task_state.exception_as_result`. When set, `mrb_vm_exec()`'s non-root_c L_RAISE branch returns the exception as a value even if prev_jmp is non-NULL, instead of throwing * `execute_task_vm()` raises the flag around `mrb_vm_exec()`, captures the exception into `t->result`, and clears `mrb->exc` * Wrap `execute_task_vm()` in `mrb_protect_error()` as a safety net for rare paths that still unwind via MRB_THROW (e.g. CINFO_SKIP frames). exception_as_result is reset both at the end of the body and immediately after `mrb_protect_error()` returns, so a caught throw does not leave the llag set * Expose `Task#value` to retrieve t->result from Ruby, since Task#join cannot deliver the value through its return path under cooperative scheduling * Add a test asserting that `Task#join` on a task that raised returns the exception object, matching the pre-c09196c observable behavior ## Notes The "task exception becomes task result" semantics match the mruby/c's rrt0.c and the spirit of CRuby's Thread (an unhandled exception in a thread does not kill the scheduler / process; it surfaces when the thread is joined). The visible API shape still differs from CRuby - `Task#join` here returns the exception object rather than re-raising it - but the scheduler is no longer destabilized by task errors in either invocation pattern. --- include/mruby.h | 1 + mrbgems/mruby-task/src/task.c | 41 +++++++++++++++++++++++++++++++-- mrbgems/mruby-task/test/task.rb | 14 +++++++++++ src/vm.c | 3 +++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/include/mruby.h b/include/mruby.h index 7bbfb9634..4a51b0973 100644 --- a/include/mruby.h +++ b/include/mruby.h @@ -275,6 +275,7 @@ typedef struct mrb_task_state { struct mrb_task *main_task; /* Main task wrapper for root context */ uint8_t scheduler_lock; /* Lock counter for synchronous execution */ mrb_bool loop_running; /* Active mrb_task_run loop flag */ + mrb_bool exception_as_result; /* Return unhandled task exceptions as values */ } mrb_task_state; #endif diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 5aa47c1ef..434b10c1d 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -314,6 +314,27 @@ task_change_state(mrb_state *mrb, mrb_task *t, uint8_t new_status) mrb_task_enable_irq(); } +typedef struct execute_task_vm_args { + mrb_task *t; + const struct RProc *proc; + const mrb_code *pc; +} execute_task_vm_args; + +static mrb_value +execute_task_vm(mrb_state *mrb, void *ud) +{ + execute_task_vm_args *args = (execute_task_vm_args*)ud; + + mrb->task.exception_as_result = TRUE; + args->t->result = mrb_vm_exec(mrb, args->proc, args->pc); + if (mrb->exc) { + args->t->result = mrb_obj_value(mrb->exc); + mrb->exc = NULL; + } + mrb->task.exception_as_result = FALSE; + return args->t->result; +} + /* Execute a single task - core task execution logic */ static void execute_task(mrb_state *mrb, mrb_task *t) @@ -348,8 +369,13 @@ execute_task(mrb_state *mrb, mrb_task *t) /* Set vmexec flag to prevent fiber_terminate from being called */ t->c.vmexec = TRUE; - /* Execute task - PC is saved in ci->pc from previous run */ - t->result = mrb_vm_exec(mrb, proc, pc); + /* Execute task - PC is saved in ci->pc from previous run. + Unhandled task exceptions are converted to the task result by + mrb_vm_exec() in task mode, so the scheduler protect frame stays intact. */ + execute_task_vm_args args = { t, proc, pc }; + mrb_bool error = FALSE; + t->result = mrb_protect_error(mrb, execute_task_vm, &args, &error); + mrb->task.exception_as_result = FALSE; /* Clear vmexec flag */ t->c.vmexec = FALSE; @@ -379,6 +405,15 @@ execute_task(mrb_state *mrb, mrb_task *t) /* Task yielded but still running - move to ready queue */ t->status = MRB_TASK_STATUS_READY; } + + /* Fallback for abnormal cases that bypass exception_as_result: + e.g. a CINFO_SKIP frame or some other path inside mrb_vm_exec() + unwound via MRB_THROW instead of returning the exception as a + value. Normal unhandled task exceptions never reach this branch; + they are captured into t->result by execute_task_vm() above. */ + if (error) { + mrb_exc_raise(mrb, t->result); + } } /* Tick handler - called by timer interrupt */ @@ -1524,6 +1559,7 @@ mrb_mruby_task_gem_init(mrb_state *mrb) mrb->task.main_task = NULL; mrb->task.scheduler_lock = 0; mrb->task.loop_running = FALSE; + mrb->task.exception_as_result = FALSE; task_class = mrb_define_class_id(mrb, MRB_SYM(Task), mrb->object_class); MRB_SET_INSTANCE_TT(task_class, MRB_TT_DATA); @@ -1555,6 +1591,7 @@ mrb_mruby_task_gem_init(mrb_state *mrb) mrb_define_method_id(mrb, task_class, MRB_SYM(resume), mrb_task_resume, MRB_ARGS_NONE()); mrb_define_method_id(mrb, task_class, MRB_SYM(terminate), mrb_task_terminate, MRB_ARGS_NONE()); mrb_define_method_id(mrb, task_class, MRB_SYM(join), mrb_task_join, MRB_ARGS_NONE()); + mrb_define_method_id(mrb, task_class, MRB_SYM(value), mrb_task_value, MRB_ARGS_NONE()); /* Kernel methods (module functions like CRuby) * Note: sleep and usleep override mruby-sleep's implementation to be task-aware diff --git a/mrbgems/mruby-task/test/task.rb b/mrbgems/mruby-task/test/task.rb index 678b136ab..5bbe844e3 100644 --- a/mrbgems/mruby-task/test/task.rb +++ b/mrbgems/mruby-task/test/task.rb @@ -194,3 +194,17 @@ assert("Task.run inside Task.run is a noop") do Task.run end end + +assert("Task#value returns exception object for unhandled task errors") do + child = nil + + Task.new do + child = Task.new { raise "boom" } + end + + Task.run + + result = child.value + assert_kind_of RuntimeError, result + assert_equal "boom", result.message +end diff --git a/src/vm.c b/src/vm.c index bb0c877f1..0a84798b0 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1635,9 +1635,11 @@ task_across_c_boundary(mrb_state *mrb) if (mrb->c->status != MRB_TASK_STOPPED) \ mrb->c->status = MRB_TASK_STOPPED; \ } while (0) +#define TASK_RETURN_EXCEPTION_AS_VALUE(mrb) ((mrb)->task.exception_as_result) #else #define RETURN_IF_TASK_STOPPED(mrb) #define TASK_STOP(mrb) +#define TASK_RETURN_EXCEPTION_AS_VALUE(mrb) FALSE #endif /** @@ -2691,6 +2693,7 @@ RETRY_TRY_BLOCK: fiber_terminate(mrb, c, ci); if (mrb_unlikely(!c->vmexec)) goto L_RAISE; mrb->jmp = prev_jmp; + if (TASK_RETURN_EXCEPTION_AS_VALUE(mrb)) return mrb_obj_value(mrb->exc); if (!prev_jmp) return mrb_obj_value(mrb->exc); MRB_THROW(prev_jmp); } From a502be4f9ffa72a67852da3ddfcc58577cdb4dce Mon Sep 17 00:00:00 2001 From: HASUMI Hitoshi Date: Fri, 29 May 2026 01:30:24 +0900 Subject: [PATCH 44/51] Fix fallback when abnormal error happens It is not likely happens but if happened, in pattern 1, the whole process abort when mrb->jmp is NULL. Instead, make the status MRB_TASK_STOPPED and delegate following logic of "Handle task termination" --- mrbgems/mruby-task/src/task.c | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 434b10c1d..2c3df174f 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -389,6 +389,18 @@ execute_task(mrb_state *mrb, mrb_task *t) prev_c->ci = prev_ci; prev_ci->cci = prev_cci; + /* If an abnormal path inside mrb_vm_exec() bypassed + exception_as_result and unwound via MRB_THROW (e.g. a + CINFO_SKIP frame), mrb_protect_error caught it and stored the + exception object in t->result. Force the task to terminate + cleanly so the scheduler keeps running instead of aborting - + re-raising into the scheduler would abort in pattern 1, where + no outer jmpbuf exists. The exception remains observable via + mrb_task_value() / Task#value. */ + if (error) { + t->c.status = MRB_TASK_STOPPED; + } + /* Handle task termination */ if (t->c.status == MRB_TASK_STOPPED) { switching_ = FALSE; @@ -405,15 +417,6 @@ execute_task(mrb_state *mrb, mrb_task *t) /* Task yielded but still running - move to ready queue */ t->status = MRB_TASK_STATUS_READY; } - - /* Fallback for abnormal cases that bypass exception_as_result: - e.g. a CINFO_SKIP frame or some other path inside mrb_vm_exec() - unwound via MRB_THROW instead of returning the exception as a - value. Normal unhandled task exceptions never reach this branch; - they are captured into t->result by execute_task_vm() above. */ - if (error) { - mrb_exc_raise(mrb, t->result); - } } /* Tick handler - called by timer interrupt */ From be36b67a128e2fb498588f12c42e13869d38abb6 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 29 May 2026 05:59:14 +0900 Subject: [PATCH 45/51] mruby-task: clear dead stack slots when marking preempted tasks mrb_task_mark_all marked a task's live registers but, unlike mark_context_stack in gc.c, never cleared the slots above the live range. When a preempted task's live range later shrank (a frame had returned), the stale object pointers left in those slots were neither marked nor cleared: the objects were swept while the pointers survived. Re-entering the same frame reused those slots, and the next mark of the resumed task hit a freed object, tripping the MRB_TT_FREE assertion in mrb_gc_mark. Clear the dead slots after marking, exactly as mark_context_stack does for the running context. Fixes #6870. Co-authored-by: Claude --- mrbgems/mruby-task/src/task.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mrbgems/mruby-task/src/task.c b/mrbgems/mruby-task/src/task.c index 5aa47c1ef..28a89ee37 100644 --- a/mrbgems/mruby-task/src/task.c +++ b/mrbgems/mruby-task/src/task.c @@ -94,6 +94,16 @@ mrb_task_mark_all(mrb_state *mrb) for (i = 0; i < e; i++) { mrb_gc_mark_value(mrb, c->stbase[i]); } + /* Clear the dead slots above the live range, matching + mark_context_stack() in gc.c. A preempted task whose live range + later shrinks (a frame returned) would otherwise leave stale + object pointers in those slots; the objects get swept while the + pointers survive, and a subsequent mark of the resumed task trips + the MRB_TT_FREE assertion in mrb_gc_mark (issue #6870). */ + size_t stend = c->stend - c->stbase; + for (; i < stend; i++) { + SET_NIL_VALUE(c->stbase[i]); + } } /* Mark call stack */ From 828efa9b8707e9692b72199bc64f644a2d52d6fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:56:25 +0000 Subject: [PATCH 46/51] build(deps): bump github/codeql-action Bumps the github-actions-dependencies group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.35.5 to 4.36.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.36.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-dependencies ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index df1c2e59a..1901f512b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,12 +22,12 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "Security" From f5ca90685273e2e9b7a6aed1dcdf1dc15be4ff1a Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Sat, 30 May 2026 07:39:18 +0900 Subject: [PATCH 47/51] mruby-compiler: fix bare nil? in if/unless to use self as receiver The if/unless nil? optimization called codegen() on the call node's receiver, but a bare `nil?` is parsed as an FCALL whose receiver is NULL. codegen(NULL) emits OP_LOADNIL, so the JMPNIL was testing the literal nil instead of self, making `if nil?` always behave as `if nil.nil?` (always true) and `unless nil?` always skip its body. Load self when the receiver is implicit. Fixes #6874. Co-authored-by: Claude --- mrbgems/mruby-compiler/core/codegen.c | 8 +++++++- test/t/codegen.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/mrbgems/mruby-compiler/core/codegen.c b/mrbgems/mruby-compiler/core/codegen.c index cf3c24d19..f041a2c99 100644 --- a/mrbgems/mruby-compiler/core/codegen.c +++ b/mrbgems/mruby-compiler/core/codegen.c @@ -3974,7 +3974,13 @@ codegen_if(codegen_scope *s, node *varnode, int val) mrb_sym sym_nil_p = MRB_SYM_Q(nil); if (call_n->method_name == sym_nil_p && callargs_empty(call_n->args)) { nil_p = TRUE; - codegen(s, call_n->receiver, VAL); + if (call_n->receiver) { + codegen(s, call_n->receiver, VAL); + } + else { + /* implicit receiver: bare `nil?` means `self.nil?` */ + gen_load_op1(s, OP_LOADSELF, VAL); + } } } diff --git a/test/t/codegen.rb b/test/t/codegen.rb index c4e031bd3..1e4d37559 100644 --- a/test/t/codegen.rb +++ b/test/t/codegen.rb @@ -194,3 +194,29 @@ assert('register window of calls (#3783)') do end end end + +assert('bare `nil?` in if/unless uses self as receiver (#6874)') do + klass = Class.new do + def unless_form + reached = false + unless nil? + reached = true + end + reached + end + + def if_form + if nil? + :yes + else + :no + end + end + end + + assert_true klass.new.unless_form + assert_equal :no, klass.new.if_form + # Sanity: explicit literal nil receiver still optimized correctly. + result = if nil.nil? then :yes else :no end + assert_equal :yes, result +end From 9d084b09b7f18230f89452c5cc0861353cb9a967 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Sun, 31 May 2026 09:09:20 +0900 Subject: [PATCH 48/51] mruby-io: write literal strings directly via fd_write_buf io_puts_str, io_puts_ary, and io_puts allocated a fresh mruby String for every "\n", empty-array marker, "[...]" overflow marker, and no-arg newline, only for fd_write to unpack it back to ptr/len. The allocations also stayed on the GC arena across the recursive walk, scaling pressure with array length. Split fd_write into fd_write_buf (the EINTR-resilient write loop over ptr/len) plus the existing mrb_value wrapper, and add a FD_WRITE_LIT macro for compile-time-known literals. Replace the four mrb_str_new_lit + fd_write pairs with FD_WRITE_LIT. The "" s "" inside the macro enforces that the argument is a string literal so sizeof(s) - 1 is the correct length. Co-authored-by: Claude --- mrbgems/mruby-io/src/io.c | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/mrbgems/mruby-io/src/io.c b/mrbgems/mruby-io/src/io.c index e38545c2c..87d6f9a46 100644 --- a/mrbgems/mruby-io/src/io.c +++ b/mrbgems/mruby-io/src/io.c @@ -911,18 +911,12 @@ io_syswrite(mrb_state *mrb, mrb_value io) /* end */ static mrb_int -fd_write(mrb_state *mrb, int fd, mrb_value str) +fd_write_buf(mrb_state *mrb, int fd, const char *ptr, mrb_int len) { - fssize_t n; - - str = mrb_obj_as_string(mrb, str); - fssize_t len = (fssize_t)RSTRING_LEN(str); if (len == 0) return 0; - - const char *ptr = RSTRING_PTR(str); fssize_t sum = 0; - while (sum < len) { - n = write(fd, ptr + sum, len - sum); + while (sum < (fssize_t)len) { + fssize_t n = write(fd, ptr + sum, (size_t)(len - sum)); if (n == -1) { if (errno == EINTR) continue; mrb_sys_fail(mrb, "syswrite"); @@ -932,6 +926,15 @@ fd_write(mrb_state *mrb, int fd, mrb_value str) return len; } +static mrb_int +fd_write(mrb_state *mrb, int fd, mrb_value str) +{ + str = mrb_obj_as_string(mrb, str); + return fd_write_buf(mrb, fd, RSTRING_PTR(str), RSTRING_LEN(str)); +} + +#define FD_WRITE_LIT(mrb, fd, s) fd_write_buf(mrb, fd, "" s "", sizeof(s) - 1) + /* Helper function to prepare IO object for writing by adjusting buffer state */ static void io_prepare_write(mrb_state *mrb, struct mrb_io *fptr) @@ -987,8 +990,7 @@ io_puts_str(mrb_state *mrb, int fd, mrb_value str) /* Add newline if string doesn't end with one */ if (len == 0 || ptr[len-1] != '\n') { - mrb_value newline = mrb_str_new_lit(mrb, "\n"); - fd_write(mrb, fd, newline); + FD_WRITE_LIT(mrb, fd, "\n"); } } @@ -1001,8 +1003,7 @@ static void io_puts_ary(mrb_state *mrb, int fd, mrb_value ary, int depth) { if (depth >= IO_PUTS_MAX_DEPTH) { - mrb_value mark = mrb_str_new_lit(mrb, "[...]\n"); - fd_write(mrb, fd, mark); + FD_WRITE_LIT(mrb, fd, "[...]\n"); return; } @@ -1010,8 +1011,7 @@ io_puts_ary(mrb_state *mrb, int fd, mrb_value ary, int depth) if (len == 0) { /* Empty array - write a single newline */ - mrb_value newline = mrb_str_new_lit(mrb, "\n"); - fd_write(mrb, fd, newline); + FD_WRITE_LIT(mrb, fd, "\n"); return; } @@ -1041,8 +1041,7 @@ io_puts(mrb_state *mrb, mrb_value io) if (argc == 0) { /* No arguments - just write a newline */ - mrb_value newline = mrb_str_new_lit(mrb, "\n"); - fd_write(mrb, fd, newline); + FD_WRITE_LIT(mrb, fd, "\n"); return mrb_nil_value(); } From 72e5ebed55b672bdff413270022c8398465c2479 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Mon, 1 Jun 2026 14:15:29 +0900 Subject: [PATCH 49/51] AUTHORS: update entries [ci skip] --- AUTHORS | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/AUTHORS b/AUTHORS index 19e7d0a12..25b3359af 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,25 +1,25 @@ # Authors of mruby (mruby developers) -## The List of Contributors sorted by number of commits (as of 2026-03-02 02877f0) +## The List of Contributors sorted by number of commits (as of 2026-05-31 9d084b0) - 7532 Yukihiro "Matz" Matsumoto (@matz)* - 712 dearblue (@dearblue)* + 7747 Yukihiro "Matz" Matsumoto (@matz)* + 749 dearblue (@dearblue)* 587 KOBAYASHI Shuji (@shuujii) 353 Daniel Bovensiepen (@bovi)* 345 Takeshi Watanabe (@take-cheeze)* 333 Masaki Muranaka (@monaka) - 255 John Bampton (@jbampton) + 266 John Bampton (@jbampton) 234 Jun Hiroe (@suzukaze) 228 Tomoyuki Sahara (@tsahara)* 220 Cremno (@cremno)* 209 Yuki Kurihara (@ksss)+ - 144 Yasuhiro Matsumoto (@mattn)* + 146 Yasuhiro Matsumoto (@mattn)* 113 Carson McDonald (@carsonmcdonald) 104 Tomasz Pędraszewski (@dabroz)* 83 Akira Yumiyama (@akiray03)* 83 skandhas (@skandhas) 80 Masamitsu MURASE (@masamitsu-murase) - 73 Hiroshi Mimaki (@mimaki)* + 79 Hiroshi Mimaki (@mimaki)* 71 Tatsuhiko Kubo (@cubicdaiya)* 71 Yuichiro MASUI (@masuidrive) 62 Yuichiro Kaneko (@yui-knk)+ @@ -36,8 +36,10 @@ 32 Masayoshi Takahashi (@takahashim)+ 31 MATSUMOTO Ryosuke (@matsumotory)* 30 Nobuyoshi Nakada (@nobu) + 29 HASUMI Hitoshi (@hasumikin) 26 Hoshiumi Arata (@hoshiumiarata)* 25 Julian Aron Prenner (@furunkel)* + 23 leviongit (@leviongit) 22 Clayton Smith (@clayton-shopify) 22 Uchio Kondo (@udzura)* 22 Zachary Scott (@zzak)* @@ -50,10 +52,9 @@ 18 Corey Powell (@IceDragon200) 18 Hidetaka Takano (@TJ-Hidetaka-Takano) 18 Jon Maken (@jonforums)+ - 18 leviongit (@leviongit) 18 mirichi (@mirichi) 17 Mitchell Blank Jr (@mitchblank)* - 16 HASUMI Hitoshi (@hasumikin) + 16 Hendrik (@Asmod4n) 16 bggd (@bggd) 16 kano4 (@kano4) 15 Felix Jones (@felixjones)* @@ -76,7 +77,7 @@ 11 RIZAL Reckordp (@Reckordp)+ 11 Seeker (@SeekingMeaning) 11 takkaw (@takkaw) - 10 Hendrik (@Asmod4n) + 10 Chris Hasiński (@khasinski) 10 Miura Hideki (@miura1729) 10 Narihiro Nakamura (@authorNari) 10 YAMAMOTO Masaya (pandax381) @@ -88,6 +89,7 @@ 8 Wataru Ashihara (@wataash)* 7 Bhargava Shastry (@bshastry)* 7 Kouichi Nakanishi (@keizo042) + 7 Paweł Świątkowski (@katafrakt) 7 Rubyist (@expeditiousRubyist) 7 Simon Génier (@simon-shopify) 7 Terence Lee (@hone) @@ -101,7 +103,6 @@ 6 INOUE Yasuyuki (@yasuyuki) 6 Junji Sawada (@junjis0203) 6 Kenji Okimoto (@okkez)+ - 6 Paweł Świątkowski (@katafrakt) 6 Selman ULUG (@selman) 6 Yusuke Endoh (@mame)* 6 buty4649 (@buty4649) @@ -120,7 +121,6 @@ 5 dreamedge (@dreamedge) 5 nkshigeru (@nkshigeru) 5 xuejianqing (@joans321) - 4 Chris Hasiński (@khasinski) 4 Dante Catalfamo (@dantecatalfamo) 4 Goro Kikuchi (@gorogit) 4 Herwin Weststrate (@herwinw) @@ -140,6 +140,7 @@ 4 Yuji Yamano (@yyamano) 4 kurodash (@kurodash)* 4 wanabe (@wanabe)* + 2 0x1eef (@0x1eef) 3 Anton Davydov (@davydovanton) 3 Aurora Nockert (@auroranockert) 3 Carlo Prelz (@asfluido)* @@ -192,6 +193,7 @@ 2 Masahiro Wakame (@vvkame)+ 2 Minao Yamamoto (@tarosay)+ 2 Nihad Abbasov (@NARKOZ) + 2 Pete Kinnecom (@petekinnecom) 2 Robert Mosolgo (@rmosolgo) 2 Russel Hunter Yukawa (@rhykw)+ 2 Ryunosuke SATO (@tricknotes) @@ -220,6 +222,7 @@ 1 Colin MacKenzie IV (@sinisterchipmunk) 1 Daehyub Kim (@lateau) 1 Daniel Varga (@vargad) + 1 David Korczynski (@DavidKorczynski) 1 Diamond Rivero (@diamant3) 1 Edgar Boda-Majer (@eboda) 1 Fangrui Song (@MaskRay) @@ -274,7 +277,6 @@ 1 Patrick Ellis (@pje) 1 Patrick Pokatilo (@SHyx0rmZ) 1 Pavel Evstigneev (@Paxa)+ - 1 Pete Kinnecom (@petekinnecom) 1 Piotr Usewicz (@pusewicz) 1 Prayag Verma (@pra85) 1 Ranmocy (@ranmocy) @@ -282,6 +284,7 @@ 1 Ryan Scott Lewis (@RyanScottLewis) 1 Ryo Okubo (@syucream) 1 SAkira a.k.a. Akira Suzuki (@sakisakira) + 1 SaekiMototsune (@saeki-mototsune) 1 Santiago Rodriguez (@sanrodari) 1 Satoh, Hiroh (@cho45)+ 1 Satoru Naba (@snaba)+ @@ -325,6 +328,7 @@ 1 sbsoftware (@sbsoftware) 1 ssmallkirby (@smallkirby) 1 taku toyama (@tsuichu) + 1 vobloeb (@vobloeb) `*` - Entries unified according to names and addresses `+` - Entries with names different from commits From 6704a385b7f29617a0d2470cdd77a4e052c1162b Mon Sep 17 00:00:00 2001 From: dearblue Date: Mon, 1 Jun 2026 22:27:59 +0900 Subject: [PATCH 50/51] Free the index array immediately at the end of `ary_combination_next()` --- mrbgems/mruby-array-ext/src/array.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mrbgems/mruby-array-ext/src/array.c b/mrbgems/mruby-array-ext/src/array.c index 285ac908a..62a731c36 100644 --- a/mrbgems/mruby-array-ext/src/array.c +++ b/mrbgems/mruby-array-ext/src/array.c @@ -1636,6 +1636,8 @@ ary_combination_next(mrb_state *mrb, mrb_value self) for (mrb_int i = 0; i < state->k; i++) { if (state->indices[i] >= state->n) { state->mode = comb_finished; + mrb_free(mrb, state->indices); + state->indices = NULL; return mrb_nil_value(); } } @@ -1698,6 +1700,8 @@ ary_combination_next(mrb_state *mrb, mrb_value self) } state->mode = comb_finished; + mrb_free(mrb, state->indices); + state->indices = NULL; return result; } From 4073ad386c5d60450747914e957f5e913d15d5c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:17:23 +0000 Subject: [PATCH 51/51] build(deps): bump yard in the bundler-dependencies group Bumps the bundler-dependencies group with 1 update: [yard](https://yardoc.org). Updates `yard` from 0.9.43 to 0.9.44 --- updated-dependencies: - dependency-name: yard dependency-version: 0.9.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: bundler-dependencies ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a3b8f6ef6..694383326 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: coderay (1.1.3) rake (13.4.2) - yard (0.9.43) + yard (0.9.44) yard-coderay (0.1.0) coderay yard