mruby-regexp: snapshot the subject string in create_matchdata() - #7053
Merged
Conversation
`MatchData` kept the subject string by reference and computed `#[]`,
`#pre_match`, `#post_match` and friends lazily from it, so mutating that
string in place retroactively changed what an already built `MatchData`
reported.
```ruby
s = "hello"
s =~ /l/
s.upcase!
$~[0] # CRuby: "l", mruby: "L"
$~.string # CRuby: "hello", mruby: "HELLO"
```
CRuby snapshots the subject with `rb_str_new_frozen()`, so `$~` always
describes the string as it was at match time, and `$~.string` is frozen.
`$&`, `` $` ``, `$'` and `$+` come out of `re_byte_substr()` at match time
and so survive the sequence above, but they are not immune in general.
`matchdata_set_globals()` recomputes them from `md->source`, and the
`String#gsub` loop in mrblib calls it after the user block has run.
```ruby
t = "hello"
n = 0
t.gsub(/l/) { n += 1; t.upcase! if n == 2; "X" }
$& # CRuby: nil, mruby: "L", where the match was "l"
```
Rebind `str` to `mrb_str_dup_frozen()` at the top of `create_matchdata()`.
That function is the sole constructor of a `MatchData`, and the three reads
of `str` below it (`md->source`, the `source` instance variable and the
`set_match_globals()` call) all see the rebound local, so the one statement
covers every construction path and with it every accessor. It also makes
true the comment on `matchdata_string()`, which already claimed to return a
frozen copy.
`mrb_str_dup_frozen()` returns an already frozen string untouched, and
`mrb_str_dup()` shares the buffer of a heap string, so only short embedded
strings are really copied. The duplicate is held in a C local until
`mrb_iv_set()` roots it, protected in that window by the GC arena that
`mrb_obj_alloc()` pushed it onto.
|
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 (2)
📝 WalkthroughWalkthroughThe regexp implementation now stores a frozen duplicate of the subject in ChangesMatchData subject snapshot
Estimated code review effort: 2 (Simple) | ~10 minutes 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 |
This was referenced Aug 9, 2026
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
MatchDatastores the subject string by reference and computes#[],#pre_match,#post_matchand friends lazily from it, so mutating that string afterwards retroactively changes what an already builtMatchDatareports.CRuby snapshots the subject with
rb_str_new_frozen(), so$~always describes the string as it was at match time, and$~.stringis frozen.Every accessor that reads the subject diverges, and on an
MRB_UTF8_STRINGbuild#beginand#endgo with them, since they read it to convert a byte offset to a character offset.s = "hello"; md = /l/.match(s); s.upcase!md[0]"l""L"md.string"hello""HELLO"md.pre_match"he""HE"md.post_match"lo""LO"md.string.frozen?truefalse$&,$`,$'and$+survive that particular sequence, because the path that sets them computes them throughre_byte_substr(), which copies the bytes at match time. They are not immune in general:matchdata_set_globals()recomputes them frommd->source, and theString#gsubloop inmrbgems/mruby-regexp/mrblib/string_regexp.rbcalls it after the user block has run.It is also a trap for the destructive string methods. One that matches
selfand then overwrites it withreplacewould leave behind a$~describing the replaced string. Not reproducible today, sincesub!andgsub!still raiseTypeErrorfor a Regexp pattern, but any implementation of them would have to know about it.Cause
create_matchdata()assignsmd->source = strwithout copying, and sets thesourceinstance variable to the same string. Nothing else in the gem copies it, so every accessor reads whatever the caller's string holds at the time it is called rather than at match time.Fix
Rebind
strtomrb_str_dup_frozen(mrb, str)at the top ofcreate_matchdata().That function is the sole constructor of a
MatchData:mrb_data_object_alloc(..., &matchdata_type)appears once, and the four call sites (exec_match(),regexp_gsub_str(),regexp_sub_str(),regexp_scan()) all pass the subject as an argument. Rebinding the parameter covers all four, and covers the three reads inside the function that have to agree with each other:md->source, thesourceinstance variable and theset_match_globals()call. Snapshotting at the call sites instead would be four edits and would leave the invariant unenforced for the fifth.No accessor changes. They are wrong because of what
md->sourceholds, and the single assignment fixes all of them at once,matchdata_set_globals()and with it thegsubcase above included. The comment onmatchdata_string()already claimed the method returns "the original string (frozen copy)"; this is what makes it true.set_match_globals()needs no edit either. Called fromcreate_matchdata()it receives the snapshot, and called frommatchdata_set_globals()it receivesmd->source, which the snapshot has already made immutable.Cost
mrb_str_dup_frozen()returns an already frozen string untouched, andmrb_str_dup()shares the buffer of a heap string, so only short embedded strings are really copied. The duplicate is held in a C local untilmrb_iv_set()roots it, protected in that window by the GC arena thatmrb_obj_alloc()pushed it onto.Best of five, two separate batches per column:
("ab" * 5000).gsub(/a/) { "x" }x20("ab" * 8).gsub(/a/) { "x" }x20000s =~ /a/x10000020000.times { s << "abcdefghijabcdefghij"; s =~ /a/ }Every row swings by more between batches than between the columns, so the duplication does not show above the noise.
The last row is the case worth naming, because the snapshot pins the buffer and so forces every subsequent in-place write to the subject through
mrb_str_modify(). That used to make the usual scanner loop quadratic. It no longer does, becausemrb_str_cat()appends into a shared buffer in place instead of copying it as of 34e208b.Testing
mrbgems/mruby-regexp/test/regexp.rbgains two cases: theupcase!sequence againstmd[0],#string,#pre_match,#post_matchand#string.frozen?through bothRegexp#matchand=~, and thegsubblock above against$&,$`and$'. Both fail on master and pass here.All green,
KO: 0andCrash: 0everywhere:rake testwithbuild_config/default.rbMRUBY_CONFIG=asan rake test(address,undefined)MRUBY_CONFIG=ci/gcc-clang rake test, which coversMRB_GC_STRESSwithMRB_USE_DEBUG_HOOK,MRB_GC_FIXED_ARENA, and the C++ ABI buildMRB_UTF8_STRINGMRB_INT32withMRB_WORD_BOXINGandMRB_UTF8_STRINGMRB_NAN_BOXINGThe
MRB_NAN_BOXINGbuild fails threemruby-string-bitopsassertions, and fails the same three with this branch reverted, so they are not from this change.Summary by CodeRabbit
Bug Fixes
Tests