Skip to content

mruby-regexp: give the pattern its buffers before the compile starts - #7232

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-compile-owns-buffers
Aug 17, 2026
Merged

mruby-regexp: give the pattern its buffers before the compile starts#7232
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-compile-owns-buffers

Conversation

@takumin

@takumin takumin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Replaces #7229, which closed one of the exits below.

mrb_re_compile() grew the pattern in a re_compiler on its own stack and copied the result into a mrb_regexp_pattern once the parse was through:

  re_compiler c;
  memset(&c, 0, sizeof(c));
  ...
  mrb_regexp_pattern *pat = (mrb_regexp_pattern*)mrb_malloc(mrb, sizeof(mrb_regexp_pattern));
  pat->code = c.code;

Nothing outside that frame could reach c.code, c.classes, c.named_captures or c.stripped while the compile ran, and a compile leaves in two ways that never arrive at the copy: compile_error() raises RegexpError for a pattern it cannot parse, and mrb_realloc() raises NoMemoryError for an allocation it cannot make. Both longjmp past the frame, and nothing unwinds it.

compile_error() answered its own exit with a block of mrb_free() calls. A refused allocation had no answer at all, and neither did the allocations made after the parse: pat itself, named_arena, prefix, cached_visited and the two cached_threads are asked for one at a time, and a failure among them loses the pattern along with everything already handed to it. On a MRB_GC_FIXED_ARENA build, which build_config/ci/gcc-clang.rb and build_config/ci/msvc.rb both are, an arena overflow opens the same exits.

The fix

regexp_init() allocates the pattern zero filled and hands it to the Regexp before asking for a compile:

  pat = (mrb_regexp_pattern*)mrb_calloc(mrb, 1, sizeof(mrb_regexp_pattern));
  DATA_TYPE(self) = &regexp_type;
  DATA_PTR(self) = pat;

  mrb_re_compile(mrb, pat, RSTRING_PTR(pattern), RSTRING_LEN(pattern), flags);

Every buffer the compile allocates hangs off an object the GC reaches, from the moment it is allocated, so regexp_free() collects them however the compile ends. re_compiler keeps what belongs to the parse: the cursor, the capacities the pattern does not record, and the counts that reach it at the end.

A pattern that can be read before it is finished has to answer for that, in three places:

  • add_class() clears a class before counting it. num_classes is what mrb_re_free() reads ranges pointers out of, so the count grows last rather than first.
  • pat->code_len is written last, after the final allocation. No pattern compiles to no instructions, so a zero there marks a compile that did not finish, and re_uninitialized_p() reads it. It takes over from the !pat tests already standing at those call sites: the object is reachable in that state, since the exception can be rescued while ObjectSpace still hands it out.
  • A second Regexp#initialize is refused with TypeError: already initialized regexp, which is CRuby's answer to it. Compiling in place would drop the pattern the object already owns with nothing left to free it, which the old code did.

That leaves compile_error() with nothing to free. The preprocessed copy of the pattern is not the pattern's to own, so it comes from mrb_temp_alloc() and the GC arena holds it for as long as the parse reads it. mark_empty_loops() takes its two arrays from one allocation for the same reason: asking twice put a raising call between the first block and anything that could free it.

Measurement

A driver that redefines mrb_basic_alloc_func to fail every allocation from the Nth on, counting only the allocations made inside a Regexp.new call, under ASan and LeakSanitizer. The leaked columns are what LeakSanitizer reports at exit; the size columns are the allocation that was refused, and they differ between the two builds because the order changes, the pattern struct now being the third rather than the twelfth.

A pattern that compiles through to the end, Regexp.new("[a] (?<n>b) " + "z"*2000, Regexp::EXTENDED). Its compile asks 20 times on master and 19 times here, the two arrays mark_empty_loops() wants now coming from one allocation:

refused master size master leaked this branch size this branch leaked
#1 16 0 16 0
#2 24 0 24 0
#3 2012 0 120 0
#4 256 2012 2012 0
#5 320 2268 256 0
#6 16 2588 320 0
#7 512 2604 16 0
#8 1024 2860 512 0
#9 2048 3372 1024 0
#10 4096 4396 2048 0
#11 8192 6444 4096 0
#12 120 10540 8192 0
#13 1 10660 1 0
#14 8032 10661 16064 0
#15 8032 18693 8032 0
#16 8032 10661 64480 0
#17 64480 18693 64480 0
#18 64480 83173 16 0
#19 16 0 48 0
#20 48 0

The same pattern with a ( appended, so that compile_error() raises. Its compile asks 14 times on master and 15 times here, the extra one being the pattern struct, which master does not reach on this path:

refused master size master leaked this branch size this branch leaked
#1 16 0 16 0
#2 24 0 24 0
#3 2013 0 120 0
#4 256 2013 2013 0
#5 320 2269 256 0
#6 16 2589 320 0
#7 512 2605 16 0
#8 1024 2861 512 0
#9 2048 3373 1024 0
#10 4096 4397 2048 0
#11 8192 6445 4096 0
#12 129 10541 8192 0
#13 2049 10541 129 0
#14 48 0 2049 0
#15 48 0

The rows master answers with a leak are the two kinds this changes. #4 to #11, in both tables, are a refused allocation in the middle of the parse, where compile_error() is never reached and nothing frees anything. #12 onward are a refused allocation after the parse: on the failing pattern those are the two mrb_format() makes for the message, and on the other they are the pattern struct and the arrays that follow it, where what is lost is the pattern together with everything already handed to it. On this branch every N reports nothing leaked, in both tables.

Nothing observable changes when the allocator answers, apart from the refusal a second initialize now gets, which mrbgems/mruby-regexp/test/regexp.rb covers. rake -m test on the default configuration and on ci/gcc-clang, whose six test runs report no failure and no warning, says so; the whole suite under the sanitizer build below is green as well, at 2340 assertions, with LeakSanitizer reporting nothing at exit.

Size

.text over every object of a build, on a clean build directory, ci/gcc-clang:

build master this branch
full-debug 3482762 3483441 +679
bintest 2351588 2352527 +939
cxx_abi 2356784 2357769 +985
byte-string 2282704 2283147 +443
ascii-case 2307431 2308378 +947

The growth is one indirection: a buffer the parser reached through c it now reaches through c->pat, and c->pat has to be loaded again after every call the parser makes. compile_seq, where the parser is inlined, takes +288 of the bintest figure and mrb_re_compile +349; the rest is re_uninitialized_p() at its six call sites.

Environment

Versions
OS Ubuntu 24.04.4 LTS, Linux 7.0.0-28-generic x86_64
CPU AMD Ryzen 9 5950X, 16 cores
C compiler gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1), clang 22.1.8 for the sanitizer build
CRuby 4.0.6 (2026-07-14) +PRISM, running rake
Builds

ci/gcc-clang, with -MMD -c, -I and -o dropped:

gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER mrbgems/mruby-regexp/src/re_compile.c
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK mrbgems/mruby-regexp/src/re_compile.c
gcc -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_CXX_EXCEPTION -DMRB_USE_CXX_ABI -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER mrbgems/mruby-regexp/src/re_compile.c
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER mrbgems/mruby-regexp/src/re_compile.c
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CASE -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER mrbgems/mruby-regexp/src/re_compile.c

The sanitizer build is full-core with MRB_GC_FIXED_ARENA, enable_debug, enable_test and enable_sanitizer "address":

MRuby::Build.new('regexp-leak-asan') do |conf|
  conf.toolchain :clang
  conf.gembox 'full-core'
  conf.enable_sanitizer "address"
  conf.enable_debug
  conf.enable_test
  conf.compilers.each do |c|
    c.defines += %w(MRB_GC_FIXED_ARENA)
  end
end
clang -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -Wzero-length-array -fsanitize=address -g3 -O0 -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER mrbgems/mruby-regexp/src/re_compile.c
Driver

Linked against that libmruby.a with clang -g -O0 -fsanitize=address. Its mrb_basic_alloc_func replaces the one in src/allocf.c, and __count_on bounds the window so the counted allocations are the ones Regexp.new makes rather than the parser's. It fails every allocation from the Nth on, not the Nth alone, because mrb_realloc() runs the GC and asks once more before it gives up.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mruby.h>
#include <mruby/compile.h>

static int counting = 0;
static int alloc_seq = 0;
static int fail_at = 0;

void *
mrb_basic_alloc_func(void *p, size_t size)
{
  if (size == 0) {
    free(p);
    return NULL;
  }
  if (counting) {
    alloc_seq++;
    if (fail_at && alloc_seq >= fail_at) {
      fprintf(stderr, "alloc #%d size %zu -> FAIL\n", alloc_seq, size);
      return NULL;
    }
    fprintf(stderr, "alloc #%d size %zu\n", alloc_seq, size);
  }
  return realloc(p, size);
}

static mrb_value count_on(mrb_state *mrb, mrb_value self) { counting = 1; return self; }
static mrb_value count_off(mrb_state *mrb, mrb_value self) { counting = 0; return self; }

static const char SCRIPT_VALID[] =
  "pat = \"[a] (?<n>b) \" + \"z\"*2000\n"
  "__count_on\n"
  "begin\n"
  "  Regexp.new(pat, Regexp::EXTENDED)\n"
  "rescue Exception => e\n"
  "end\n"
  "__count_off\n";

static const char SCRIPT_BROKEN[] =
  "pat = \"[a] (?<n>b) \" + \"z\"*2000 + \"(\"\n"
  "__count_on\n"
  "begin\n"
  "  Regexp.new(pat, Regexp::EXTENDED)\n"
  "rescue Exception => e\n"
  "end\n"
  "__count_off\n";

int
main(int argc, char **argv)
{
  mrb_state *mrb;
  const char *script = SCRIPT_BROKEN;

  if (argc > 1) fail_at = atoi(argv[1]);
  if (argc > 2 && strcmp(argv[2], "valid") == 0) script = SCRIPT_VALID;

  mrb = mrb_open();
  mrb_define_method(mrb, mrb->object_class, "__count_on", count_on, MRB_ARGS_NONE());
  mrb_define_method(mrb, mrb->object_class, "__count_off", count_off, MRB_ARGS_NONE());
  mrb_load_string(mrb, script);
  counting = 0;
  fail_at = 0;
  fprintf(stderr, "allocations in window: %d\n", alloc_seq);
  mrb_close(mrb);
  return 0;
}

Summary by CodeRabbit

  • Bug Fixes

    • Improved regular expression compilation reliability, including safer handling of incomplete or failed patterns.
    • Prevented regular expressions from being initialized more than once; repeated initialization now raises a TypeError.
    • Strengthened validation across matching and replacement operations to avoid using uninitialized patterns.
  • Tests

    • Added coverage for repeated initialization of Regexp subclasses.

`mark_empty_loops()` called `mrb_calloc()` twice, once for `delta` and once
for `seen`. `mrb_calloc()` raises `NoMemoryError` when it cannot answer, and
that raise longjmps out of a function whose frame is the only owner `delta`
has, so a failure on the second call loses the first block.

Both arrays hold `code_len + 1` elements of four bytes, so one allocation
covers them with `seen` starting at `delta + n`. What is left is a single
raising call, made while nothing is owned yet, and a single `mrb_free()`.
`regexp_init()` compiled the pattern and wrote the result over `DATA_PTR`
whatever was there before, so calling `initialize` again on a Regexp that
already holds a pattern lost that pattern: nothing else points at it and
`regexp_free()` only ever sees the one the object ends up with.

CRuby answers a second call with `TypeError: already initialized regexp`
rather than compiling in place, which is the same answer this needs, and it
reports a bad argument first, so the check goes after the conversions.

```ruby
class ReTwice < Regexp
  def initialize(a, b) super(a); super(b) end
end
ReTwice.new("abc", "xyz")   # CRuby: TypeError, mruby: the "abc" pattern leaks
```
`mrb_re_compile()` grew `code`, `classes`, `named_captures` and the
preprocessed copy of the pattern in a `re_compiler` on its own stack, and
copied them into a freshly allocated `mrb_regexp_pattern` once the parse was
through. Nothing outside that frame could reach them while the compile ran,
and a compile leaves in two ways that skip the end of the function:
`compile_error()` raises `RegexpError` for a pattern it cannot parse, and
`mrb_realloc()` raises `NoMemoryError` for an allocation it cannot make.
Either one longjmps past the frame, which nothing unwinds, so every buffer
live at that point was lost. `compile_error()` carried a block of `mrb_free()`
calls for its own exit; the allocation failures had no such answer, and
neither did the allocations after the parse, where a failure lost the
pattern and everything already copied into it.

The pattern is now allocated zeroed by `regexp_init()` and handed to the
Regexp before the compile is asked for anything, so the buffers hang off an
object the GC can reach from the first byte and `regexp_free()` collects them
however the compile ends. `re_compiler` keeps the parse's own state: the
cursor, the capacities the pattern does not record, and the counts that reach
it at the end. Two rules follow from a pattern that may be read before it is
finished: `add_class()` clears a class before counting it, since the count is
what `mrb_re_free()` reads range pointers out of, and `code_len` is written
last, after the final allocation, which makes a zero there the mark of a
compile that did not finish. `re_uninitialized_p()` reads it, taking over
from the `!pat` tests that stood at those call sites.

That leaves `compile_error()` with nothing to free, and the preprocessed
pattern, which is not the pattern's to own, comes from `mrb_temp_alloc()` so
that the GC arena holds it for as long as the parse reads it.

A driver that refuses every allocation from the Nth on, counting the ones a
`Regexp.new` call makes, run under LeakSanitizer: for the 19 allocations of
`Regexp.new("[a] (?<n>b) " + "z"*2000, Regexp::EXTENDED)` and the 15 of the
same pattern with a `(` appended, every N now reports nothing leaked.
@takumin
takumin requested a review from matz as a code owner August 17, 2026 06:42
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ca151d49-c2d6-468e-a9d0-23199dcbed82

📥 Commits

Reviewing files that changed from the base of the PR and between 03daa66 and 8bb526e.

📒 Files selected for processing (4)
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-regexp/test/regexp.rb

Included review availability: Your plan includes up to 8 reviews per rolling hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Regexp compilation now fills a caller-owned pattern and publishes code_len after successful initialization. Compiler buffers move into pattern-owned storage. Regexp operations reject partial patterns, and repeated initialization is covered by a regression test.

Changes

Regexp pattern lifecycle

Layer / File(s) Summary
Pattern ownership and compiler contract
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_compile.c
mrb_re_compile now fills a caller-provided pattern. Compilation uses pattern-owned state and temporary preprocessing storage.
Pattern-owned bytecode and metadata
mrbgems/mruby-regexp/src/re_compile.c
Instruction emission, character classes, named captures, lookaround metadata, optimization analysis, and empty-loop analysis now use pattern-owned storage. code_len is published after finalization.
Initialization guards and regression coverage
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/regexp.rb
Regexp initialization attaches the pattern before compilation and rejects repeated initialization. Matching and replacement paths reject partially initialized patterns. A double-super initializer test expects TypeError.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 8bb52

The PR changes regexp compilation ownership so allocations remain reclaimable during errors and allocation failures; reported tests and sanitizer checks are clean, and no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

  • mruby/mruby#7229: Both changes update compiler-buffer ownership and error cleanup in re_compile.c.
  • mruby/mruby#7166: Both changes update capture-name ownership in the regexp compiler.
  • mruby/mruby#7031: Both changes modify mrb_re_compile and compiler state handling.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: assigning pattern-owned buffers before compilation begins.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matz
matz merged commit bf944e8 into mruby:master Aug 17, 2026
21 checks passed
@takumin
takumin deleted the regexp-compile-owns-buffers branch August 17, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants