Skip to content

mruby-regexp: free the compile buffers before the message is built - #7229

Closed
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-compile-error-free-first
Closed

mruby-regexp: free the compile buffers before the message is built#7229
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-compile-error-free-first

Conversation

@takumin

@takumin takumin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

compile_error() builds the message string first and frees the four compile buffers afterwards:

  mrb_value emsg = mrb_format(c->mrb, "%s: /%l/",
                              msg, c->orig, (size_t)(c->orig_end - c->orig));

  /* Free compile buffers before raising, since mrb_exc_raise longjmps out
     and the stack-local re_compiler is abandoned without a chance to clean
     up. mrb_free doesn't trigger GC, so emsg stays valid across these. */
  mrb_free(c->mrb, c->code);

The comment names the longjmp out of mrb_exc_raise, and misses that mrb_format leaves the same way. A failing allocation raises mrb->nomem_err, which is itself an mrb_exc_raise, so the four mrb_free calls below are never reached. re_compiler is a stack local of mrb_re_compile() and there is nothing above it that owns those buffers, so code, classes, named_captures and stripped go with it. On a MRB_GC_FIXED_ARENA build, which build_config/ci/gcc-clang.rb and build_config/ci/msvc.rb both use, an arena overflow in gc_arena_keep() opens the same exit.

The message quotes the pattern in full, so the size of that allocation is the size of the pattern. A short pattern makes a message that fits in an embedded string and never asks the allocator at all; the window belongs to long patterns.

The fix

Move the three allocating calls, mrb_format, mrb_exc_get_id and mrb_exc_new_str, below the frees. emsg reads only c->orig and c->orig_end, which mrb_re_compile() sets from the caller's pattern before it swaps pattern for c.stripped, so c->orig never points into a buffer being freed here and the message still quotes the pattern as written.

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, running

Regexp.new("[a] (?<n>b) " + "z"*2000 + "(", Regexp::EXTENDED)

under ASan and LeakSanitizer. This pattern's compile asks 14 times. The size column is the allocation that was refused; the two leaked columns are what LeakSanitizer reports at exit.

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

#12 and #13 are the two allocations mrb_format makes, the buffer growth and then the 2049-byte message itself (unmatched '(': / plus the 2013-byte preprocessed pattern plus /). The 10541 bytes they lost are the compile buffers: 8192 of code, 320 of one class's ranges, 16 of the class table and 2013 of stripped. #14 allocates the exception object, after the frees in both orders, so it loses nothing either way.

Rows #4 to #11 are a failure part-way through the compile, and they are unchanged because they are a different defect: mrb_re_compile() has nothing that catches a longjmp out of the middle of a parse. That is not what this PR is about.

The stack LeakSanitizer gives for the 320-byte object at #13 before the change:

Direct leak of 320 byte(s) in 1 object(s) allocated from:
    #4 add_class          mrbgems/mruby-regexp/src/re_compile.c:196
    #5 compile_charclass  mrbgems/mruby-regexp/src/re_compile.c:732
    #6 compile_atom       mrbgems/mruby-regexp/src/re_compile.c:1371
    ...
    #10 mrb_re_compile    mrbgems/mruby-regexp/src/re_compile.c:2137

Nothing observable changes when the allocator answers, so there is no test to add: the message, its wording and the exception class are the same, and rake -m test on the default configuration and on ci/gcc-clang, whose six builds report no failure and no warning, says so.

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 for rake -m test, clang 22.1.8 for the sanitizer build
CRuby 4.0.6 (2026-07-14) +PRISM, running rake
Sanitizer build and driver

The measurement build is full-core with MRB_GC_FIXED_ARENA, enable_debug 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.compilers.each do |c|
    c.defines += %w(MRB_GC_FIXED_ARENA)
  end
end

-MMD -c, -I and -o dropped:

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

The driver, linked against that libmruby.a with clang -g -O0 -fsanitize=address. Its own 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 <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[] =
  "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;
  if (argc > 1) fail_at = atoi(argv[1]);
  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 error handling to reliably release temporary resources before reporting compilation errors.
    • Preserved accurate formatting of invalid pattern messages, including patterns containing special or nonstandard characters.

`compile_error()` builds the message string first and frees the four
compile buffers afterwards. The comment above the frees is written for
the longjmp out of `mrb_exc_raise`, but `mrb_format` longjmps too: a
failing allocation raises `mrb->nomem_err`, and on `MRB_GC_FIXED_ARENA`
builds an arena overflow leaves the same way. Neither reaches the frees,
and `re_compiler` is a stack local of `mrb_re_compile()`, so `code`,
`classes`, `named_captures` and `stripped` are lost with it.

The message quotes the pattern in full, so a long pattern makes that
allocation large. A short one fits in an embedded string and never asks
the allocator at all, which is why the window only opens on long
patterns.

Move the three allocating calls after the frees. `emsg` reads only
`c->orig` and `c->orig_end`, which `mrb_re_compile()` sets from the
caller's pattern before `pattern` is swapped for `c.stripped`, so the
message still quotes a buffer that outlives the compile.

Failing every allocation from the Nth on, with
`Regexp.new("[a] (?<n>b) " + "z"*2000 + "(", Regexp::EXTENDED)` under
ASan and LeakSanitizer, this pattern's compile asks 14 times. The two
allocations inside `compile_error()`, #12 and #13, each lost 10541
bytes and now lose none. The other rows are unchanged.
@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: 6984d713-7d6c-49e3-8adb-f76c97cc5e9c

📥 Commits

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

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/src/re_compile.c

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


📝 Walkthrough

Walkthrough

The regexp compiler now releases compiler-owned buffers before constructing formatted compilation errors. Error formatting still uses the original, unpreprocessed pattern and its explicit length.

Changes

Regexp compilation error cleanup

Layer / File(s) Summary
Cleanup before error formatting
mrbgems/mruby-regexp/src/re_compile.c
compile_error frees compiler-owned buffers before allocation-capable error construction. It then formats the original pattern with its explicit source length.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to cb433

The change frees temporary regexp compile buffers before building allocation-failure messages, preventing the documented leak without changing message or exception behavior; no actionable merge-blocking risk remains after normal checks and review.

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 and concisely describes the main change: freeing compile buffers before building the error message.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

@takumin

takumin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Closing this in favour of a structural fix.

The leak this patch closes is one exit of a wider defect: nothing the compiler allocates is reachable from a GC object while the compile runs, so every raising call inside mrb_re_compile() loses whatever is live at that point. Reordering the frees in compile_error() closes the mrb_format() exit and leaves the others open: a mrb_realloc() failure part-way through the parse, which is rows #4 to #11 of the table above, and the allocations after the parse (pat itself, named_arena, prefix, cached_visited, cached_threads), which the driver above never reaches because its pattern is invalid.

The replacement removes the class rather than the instance. mrb_regexp_pattern is allocated zeroed and attached to the Regexp before the compile starts, so the GC owns every buffer from the first byte and mrb_re_free() becomes the only free path; the compile-only temporaries (stripped, and delta/seen in mark_empty_loops()) come from mrb_temp_alloc(), which the arena owns. compile_error() is then left with nothing to free, and the frees this patch reorders go away with it.

@takumin takumin closed this Aug 17, 2026
@takumin
takumin deleted the regexp-compile-error-free-first branch August 17, 2026 06:09
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.

1 participant