mruby-regexp: give the pattern its buffers before the compile starts - #7232
Conversation
`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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 3 remain after this review. 📝 WalkthroughWalkthroughRegexp compilation now fills a caller-owned pattern and publishes ChangesRegexp pattern lifecycle
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Replaces #7229, which closed one of the exits below.
mrb_re_compile()grew the pattern in are_compileron its own stack and copied the result into amrb_regexp_patternonce the parse was through:Nothing outside that frame could reach
c.code,c.classes,c.named_capturesorc.strippedwhile the compile ran, and a compile leaves in two ways that never arrive at the copy:compile_error()raisesRegexpErrorfor a pattern it cannot parse, andmrb_realloc()raisesNoMemoryErrorfor an allocation it cannot make. Both longjmp past the frame, and nothing unwinds it.compile_error()answered its own exit with a block ofmrb_free()calls. A refused allocation had no answer at all, and neither did the allocations made after the parse:patitself,named_arena,prefix,cached_visitedand the twocached_threadsare asked for one at a time, and a failure among them loses the pattern along with everything already handed to it. On aMRB_GC_FIXED_ARENAbuild, whichbuild_config/ci/gcc-clang.rbandbuild_config/ci/msvc.rbboth 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: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_compilerkeeps 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_classesis whatmrb_re_free()readsrangespointers out of, so the count grows last rather than first.pat->code_lenis written last, after the final allocation. No pattern compiles to no instructions, so a zero there marks a compile that did not finish, andre_uninitialized_p()reads it. It takes over from the!pattests already standing at those call sites: the object is reachable in that state, since the exception can be rescued whileObjectSpacestill hands it out.Regexp#initializeis refused withTypeError: 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 frommrb_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_functo fail every allocation from the Nth on, counting only the allocations made inside aRegexp.newcall, under ASan and LeakSanitizer. Theleakedcolumns are what LeakSanitizer reports at exit; thesizecolumns 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 arraysmark_empty_loops()wants now coming from one allocation:#1#2#3#4#5#6#7#8#9#10#11#12#13#14#15#16#17#18#19#20The same pattern with a
(appended, so thatcompile_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:#1#2#3#4#5#6#7#8#9#10#11#12#13#14#15The rows master answers with a leak are the two kinds this changes.
#4to#11, in both tables, are a refused allocation in the middle of the parse, wherecompile_error()is never reached and nothing frees anything.#12onward are a refused allocation after the parse: on the failing pattern those are the twomrb_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
initializenow gets, whichmrbgems/mruby-regexp/test/regexp.rbcovers.rake -m teston the default configuration and onci/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
.textover every object of a build, on a clean build directory,ci/gcc-clang:full-debugbintestcxx_abibyte-stringascii-caseThe growth is one indirection: a buffer the parser reached through
cit now reaches throughc->pat, andc->pathas to be loaded again after every call the parser makes.compile_seq, where the parser is inlined, takes +288 of thebintestfigure andmrb_re_compile+349; the rest isre_uninitialized_p()at its six call sites.Environment
Versions
Builds
ci/gcc-clang, with-MMD -c,-Iand-odropped:The sanitizer build is
full-corewithMRB_GC_FIXED_ARENA,enable_debug,enable_testandenable_sanitizer "address":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.cDriver
Linked against that
libmruby.awithclang -g -O0 -fsanitize=address. Itsmrb_basic_alloc_funcreplaces the one insrc/allocf.c, and__count_onbounds the window so the counted allocations are the onesRegexp.newmakes rather than the parser's. It fails every allocation from the Nth on, not the Nth alone, becausemrb_realloc()runs the GC and asks once more before it gives up.Summary by CodeRabbit
Bug Fixes
TypeError.Tests
Regexpsubclasses.