| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
An inline toggle `(?imx)` applies to the rest of the enclosing group in both engines, but the two disagreed about what "the rest" is when an alternation follows. Onigmo wraps everything after the toggle, alternatives included, into one group under the new options, so `a(?i)b|c` is `a(?i:b|c)`: `parse_exp()`'s "option only" branch hands the remainder to `parse_subexp()`, which is the alternation level. `compile_atom()` set `c->flags` and returned to `compile_seq()`, so the `|` split at the level the toggle was written at, `a(?i)b|c` being `(?:a(?i)b)|c`, and the `c` matched with no `a` before it: ```ruby /a(?i)b|c/ =~ "c" # CRuby: nil, mruby: 0 /(a(?i)b|c)d/ =~ "acd" # CRuby: 0, mruby: 1 /x(a(?i)b|c)d/ =~ "xacd" # CRuby: 0, mruby: nil ``` The options themselves reached the right atoms either way, which is why the divergence showed only in the shape of the alternation. A toggle-off leaked alike, `/a(?-i)b|c/i` leaving the `c` case-sensitive, and so did `(?x)`, whose free-spacing side already agreed. Compile that remainder in the toggle arm with `compile_alt()`, under the new flags, and restore them after it. `compile_alt()` consumes every `|` and stops at the group's `)` or the end of the pattern, which is where the arm's caller already expects the parse point, so what follows is unchanged: the enclosing `compile_seq()` finds its terminator, `mrb_re_compile()` still refuses a stray `)` at top level, and `(?i)*` is still `target of repeat operator is not specified`, refused now by the `compile_seq()` that reads the toggle's scope. Capture numbering does not move either, groups being counted in source order. Checked against CRuby 4.0.6: every row above, and a differential run of 4000 toggle-heavy patterns against 15 subjects each. Of the 14760 answers that parted from CRuby there, 14750 agree now. The ten that still part part the same way when `(?i:` is written where the toggle stands, so what they are about is not the toggle's scope: `/[BA]*(?i:A)/ =~ "A"` is `nil` in CRuby and `0` here, and `/((|b[ab])+[aB])+/ =~ "aB"` parts with no option anywhere in it. Three of those 4000 patterns now reach `step limit over (MRB_REGEXP_STEP_LIMIT)` where they used to answer. All three are nested quantified alternations carrying a toggle in nearly every sequence, and splitting inside the toggle's scope rather than at the level above changes what the search retries, so the same limit is reached by a different walk. CRuby answers `nil` for all three, and two of them answered wrongly here before. A control run of 124,000 cases over patterns with no toggle in them answers identically before and after.
The parser recurses once per nesting level (`compile_alt()` calls `compile_seq()` calls `compile_quantified()` calls `compile_atom()`, whose group, lookaround, atomic and inline-option arms call `compile_alt()` again) and nothing counted the levels, so a deep enough pattern reached the end of the C stack. A pattern of 50000 nested `(?:` was a SIGSEGV rather than an error, and the commit before this one gave the toggle a second way there: `(?i)` now encloses the rest of its group, which is a level, where before it set a flag and returned. Count the levels at `compile_alt()`, the one entry the recursion passes through, and refuse a pattern past `MRB_REGEXP_PARSE_DEPTH_LIMIT` with `parse depth limit over`, which is CRuby's message for the same refusal. Every construct that opens a level is counted, the toggle among them, as Onigmo counts them: a pattern of 4096 nested `(?:` and one of 4096 nested `(?i)` are refused alike there, which is the tree this parser now builds too. The default is 4096, Onigmo's `ONIG_MAX_PARSE_DEPTH`, so a pattern is refused exactly where CRuby refuses it, 4095 levels compiling and 4096 raising on both. What that costs is stack, and the header says so where a build will read it: a level takes 560 bytes on a 64-bit gcc `-O2` build, 528 at `-Os`, and 592 on the clang `-O3` build measured here, which is `compile_alt()`'s frame plus `compile_seq()`'s as `-fstack-usage` reports them, the rest inlining into the two. On that clang build the deepest nesting that survives `ulimit -s 1024` is 1660 levels, 364 under 256 and 42 under 64. The gcc figures are what they were before this commit, and so is clang's at `-Os`; at `-O3` clang stops folding `compile_seq()` into the level, which takes it from 376 bytes to 592. So the deepest pattern the default accepts spends some 2.4 MiB, and a deeper one spends the same before being refused, the count being reached at the bottom of the recursion. A build whose stack is smaller than that, the 1 MiB a Windows thread is given among them, has to set the limit to what it can pay for or keep the crash this limit is here to prevent. A third of the stack is the share to size it from, the compiler not being the only thing standing on it: 512 for 1 MiB, 128 for 256 KiB, 32 for 64 KiB. Nesting this deep is not what a written pattern does, so a build that lowers it still takes every pattern anyone writes and gives up only the CRuby-exact refusal point. `Regexp::PARSE_DEPTH_LIMIT` reads the value back, as `Regexp::STACK_LIMIT` and `Regexp::STEP_LIMIT` read theirs, and the test sizes its patterns from it so it holds whatever a build sets. A nesting inside the limit compiles and matches on every build. The refusal is asked only of a build whose limit is at most 512: reaching a limit costs the stack that limit stands for, and a test that made a CRuby-exact build descend 4095 levels would spend the megabytes this limit exists to keep a pattern from spending. Checked at 48, 512, 1024 and 4096: the suite is green at each, the refusal asked at the first two and skipped at the last two, for a plain group, a scoped option group, a lookahead, an atomic group and a toggle. Past the limit nothing reaches the end of the stack any more where the stack can hold the limit: at 512, a pattern of 5000 nested `(?:` raises on a 1 MiB stack where it crashed before.
`parse depth limit over` had no home in CI. Reaching a limit costs the C stack that limit stands for, about 600 bytes a level, so the test in mruby-regexp/test/regexp_syntax.rb skips a build whose limit stands above 512: at the CRuby-exact default of 4096 a pattern descends some 2.4 MiB before being refused, which is more stack than a Windows thread has at all, and no test may spend what the limit exists to keep a pattern from spending. Nothing else could lift that skip. Which platforms could pay 2.4 MiB is not a question the test can ask: mruby defines no `RUBY_PLATFORM`, the one signal it has tells Windows from the rest through `File::ALT_SEPARATOR`, and Cosmopolitan, which runs the suite and whose main stack is smaller than a POSIX host's, is not distinguishable from Linux by it. Skipping only the builds a guess calls small would bet the Cosmopolitan and MinGW jobs on that guess. So let a build set a limit a test may reach instead, which is what the `ascii-ctype` build here now does at 512, some 300 KiB. That build already exists to give an mruby-regexp refusal a home, what `/i` answers where the build has no folding table, and the depth a pattern may nest is not what ASCII classification is about, so the two do not tread on each other. It rides along there rather than in a build of its own so the matrix runs no extra pass. What this covers is the refusal, not the constant: the arithmetic is `++c->depth > MRB_REGEXP_PARSE_DEPTH_LIMIT` and is the same at any limit. Checked at 48, 512, 1024 and 4096 that the boundary holds either way, one level inside compiling and one level past raising, and with 512 alongside `MRB_USE_ASCII_CTYPE`, as this build has it, the suite is green with the depth assertions running rather than skipped.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 3197847f-6c89-4ab0-80e4-2b61465d2e5a 📥 CommitsReviewing files that changed from the base of the PR and between 44ab336 and 029a7d0. 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. 📝 Walkthrough WalkthroughThe regexp compiler gains a configurable parser nesting limit, validates the setting, exposes it as Regexp::PARSE_DEPTH_LIMIT, changes inline-option scoping, and adds regression coverage. The CI configuration sets a lower limit for overflow testing. ChangesRegexp parser depth limit
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: ⚪ Minimal · up to 029a7 The PR corrects inline-toggle alternation handling and adds bounded parser-depth protection; no actionable merge-blocking risk remains beyond normal checks and review. Suggested reviewers: matz Sequence Diagram(s)sequenceDiagram
participant RegexpCompiler
participant compile_alt
participant compile_alt_body
RegexpCompiler->>compile_alt: compile nested alternation
compile_alt->>compile_alt: track parser depth
compile_alt->>compile_alt_body: compile alternation scope
compile_alt_body-->>compile_alt: return compiled body
compile_alt-->>RegexpCompiler: return or raise RegexpError
Explanation The title clearly describes the inline-toggle scoping change, which is a primary objective of the pull request. It is concise and specific. The additional parse-depth safeguard does not need to appear in the title. Full details: Docstring CoverageExplanation Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 5 files. (1 skipped: 1 unsupported.) ✨ 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. ❤️ ShareComment @coderabbitai help to get the list of available commands. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary
An inline toggle (?imx) encloses the rest of the group it stands in, alternatives and all, and this engine split its alternation one level too high, so an alternative written after a toggle matched on its own. Fixing that gives the parser a second way to recurse, so how deep a pattern may nest is bounded here too.
Changes
An alternation after an inline toggle split one level too high
Onigmo wraps everything after the toggle, alternatives included, into one group under the new options, so a(?i)b|c is a(?i:b|c): parse_exp()'s "option only" branch hands the remainder to parse_subexp(), which is the alternation level. compile_atom() set c->flags and returned to compile_seq(), so the | split at the level the toggle was written at, a(?i)b|c being (?:a(?i)b)|c, and the c matched with no a before it:
The options themselves reached the right atoms either way, which is why the divergence showed only in the shape of the alternation. A toggle-off leaked alike, /a(?-i)b|c/i leaving the c case-sensitive, and so did (?x), whose free-spacing side already agreed.
The toggle arm now compiles that remainder with compile_alt() under the new flags and restores them after. compile_alt() consumes every | and stops at the group's ) or the end of the pattern, which is where the arm's caller already expects the parse point, so what follows is unchanged: the enclosing compile_seq() finds its terminator, mrb_re_compile() still refuses a stray ) at top level, (?i)* is still target of repeat operator is not specified, and capture numbering does not move.
The parser had no bound on its own recursion
compile_alt() calls compile_seq() calls compile_quantified() calls compile_atom(), whose group, lookaround, atomic and inline-option arms call compile_alt() again, so the parser recurses once per nesting level. Nothing counted the levels, and a deep enough pattern ran off the C stack: a pattern of 50000 nested (?: was a SIGSEGV rather than an error. That is pre-existing, but the change above gives the toggle a second way there, since (?i) now opens a level where before it set a flag and returned, so the bound belongs in this PR.
compile_alt(), the one entry the recursion passes through, now counts levels and refuses past MRB_REGEXP_PARSE_DEPTH_LIMIT with parse depth limit over, CRuby's message for the same refusal. Every construct that opens a level counts, the toggle among them, as Onigmo counts them: 4096 nested (?: and 4096 nested (?i) are refused alike there, which is independent confirmation of the tree this parser now builds.
The default is 4096, Onigmo's ONIG_MAX_PARSE_DEPTH, so a pattern is refused exactly where CRuby refuses it, 4095 levels compiling and 4096 raising on both. What that costs is stack. A level takes 560 bytes on a 64-bit gcc -O2 build, 528 at -Os and 592 on the clang -O3 build measured here, which is compile_alt()'s frame plus compile_seq()'s as -fstack-usage reports them, the rest inlining into the two. Measured as the deepest nesting that survives a given ulimit -s, that is:
So the deepest pattern the default accepts spends some 2.4 MiB, and so does a deeper one before being refused, since the count is reached at the bottom of the recursion. A build on a smaller stack than that has to lower the limit or keep the crash it exists to prevent; the header and the README both say so, with the -fstack-usage recipe for measuring a build's own per-level figure. The right-hand column is a third of the stack, the compiler not being the only thing standing on it. Nesting this deep is not what a written pattern does, a handful of levels being ordinary and dozens unusual, so a build that sets 128 still takes every pattern anyone writes and gives up only the CRuby-exact refusal point. Regexp::PARSE_DEPTH_LIMIT reads the value back, as Regexp::STACK_LIMIT and Regexp::STEP_LIMIT read theirs.
That trade, a CRuby-exact refusal point against a stack cost a small-stack build must opt out of, is the one choice in this PR a build can be asked to revisit.
The refusal needs a build that can reach it
Reaching a limit costs the stack that limit stands for, so the test skips a build whose limit stands above 512: at the default a pattern descends some 2.4 MiB before being refused, which is more stack than a Windows thread has at all, and no test may spend what the limit exists to keep a pattern from spending.
Nothing else could lift that skip. Which platforms could pay 2.4 MiB is not a question the test can ask: mruby defines no RUBY_PLATFORM, the one signal it has tells Windows from the rest through File::ALT_SEPARATOR, and Cosmopolitan, which runs the suite and whose main stack is smaller than a POSIX host's, is not distinguishable from Linux by it. So a build sets a limit a test may reach instead, which is what the ascii-ctype build now does at 512, some 300 KiB. That build already exists to give an mruby-regexp refusal a home, what /i answers where the build has no folding table, and the depth a pattern may nest is not what ASCII classification is about, so the two do not tread on each other. It rides along there rather than in a build of its own so the matrix runs no extra pass.
Behaviour
Three things an existing pattern can notice.
An alternative after a toggle now wants what precedes the toggle, which is the fix: the three rows above, and a toggle-off and an (?x) alike.
A pattern nested past the limit raises RegexpError where it used to crash or compile. At the default that is 4096 levels or more, where CRuby raises the same error; a build that lowers the limit moves the point.
Three patterns in a 4000-pattern toggle fuzz now reach step limit over (MRB_REGEXP_STEP_LIMIT) where they used to answer. All three are nested quantified alternations carrying a toggle in nearly every sequence; splitting inside the toggle's scope rather than at the level above changes what the search retries, so the same step limit is reached by a different walk. CRuby answers nil for all three, and two of them answered wrongly here before. A control run of 124,000 cases over patterns with no toggle in them answers identically before and after.
Size
.text of bin/mruby, size -A, for the five builds of build_config/ci/gcc-clang.rb, master and this branch built at the same path in an empty build directory:
Two objects account for it: re_compile.o .text grows by 560, 448, 432, 560 and 112 bytes down that column, and regexp.o by 48, 48, 48, 48 and 80 for the new constant. The ascii-ctype row also carries the MRB_REGEXP_PARSE_DEPTH_LIMIT=512 this PR gives that build, which changes one constant.
For a build that carries the gem on flash, re_compile.o .text at -Os grows by 50 bytes under gcc (13,863 to 13,913) and 56 under clang (15,638 to 15,694), against some 87 KiB for the whole gem. RAM is one uint32_t on re_compiler, which lives on the C stack for the duration of a compile; no heap allocation changes. The C stack per nesting level is what it was under gcc, 560 bytes at -O2 and 528 at -Os, and under clang -Os (480); at clang -O3 it goes from 376 to 592, clang no longer folding compile_seq() into the level. What changed everywhere is that (?i) now opens a level of its own.
Testing
(the ascii-ctype build is the one that sets the limit to 512; the error echoes the whole pattern, elided here.)
Environment
Host, toolchain and the compile line of each buildcxx_abi links through clang++; the other four link through clang. The compile line of re_compile.c in each build, with -MMD -c, the -I paths and -o dropped:
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests