string.c: append into a shared buffer instead of copying it - #7039
Conversation
`mrb_str_cat()` takes `len` as a `size_t` and hands it to `mrb_int_add_overflow()`, whose parameters are `mrb_int`. A `len` above `MRB_INT_MAX` is therefore converted before the check can see it, and the conversion yields a negative value on the builds mruby targets: the addition does not overflow, `total` comes out below the current length, the capacity branch is skipped, and `memcpy()` still runs with the original `size_t` count and writes past the buffer. Nothing inside mruby reaches this. Every internal caller passes a length that came from a string, and `str_check_length()` keeps those below `MRB_INT_MAX`. An extension calling the public `mrb_str_cat()` with a length it computed itself can. Reject a `len` that does not fit before it is converted. The whole size check moves ahead of `mrb_str_modify()` as well, so a string that is about to raise is no longer unshared first.
📝 WalkthroughWalkthroughThe change tracks visible ranges in shared string buffers, reuses spare capacity for safe appends, validates append sizes, and adds regression tests for sharing scenarios. ChangesShared-buffer string append handling
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant mrb_str_cat
participant str_modify_cat
participant SharedBuffer
mrb_str_cat->>str_modify_cat: validate append length and total size
str_modify_cat->>SharedBuffer: inspect reserved boundary and capacity
SharedBuffer-->>str_modify_cat: reuse capacity or require detachment
str_modify_cat-->>mrb_str_cat: complete String#concat
🚥 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
mrbgems/mruby-string-ext/test/string.rb (1)
129-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThese two groups may not reach the in-place append path.
Groups at lines 131-136 and 138-143 build
eandgwith"b" * 100and"c" * 100. That allocation can have capacity equal to the length, with no spare bytes.str_modify_catthen fails the capacity test and both appends detach. The assertions still pass, so the intended "first append claims the bytes" case is not proven.Grow the buffer first, as the group at lines 112-114 does.
♻️ Proposed change to create spare capacity
- e = "b" * 100 + e = "b" * 100 + e << "b" * 100 + e = e[0, 100] f = e[70, 30]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mrbgems/mruby-string-ext/test/string.rb` around lines 129 - 143, Update the setup for the e/f and g/h append scenarios in the string tests so each base string is grown before creating its substring, matching the spare-capacity preparation used around the earlier test group. Preserve the existing substring offsets, append order, and assertions while ensuring both cases exercise the in-place append path.src/string.c (1)
3179-3184: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMove the
size_error:label out of theifbody.The label sits inside the
ifblock. Thegoto size_error;at line 3193 jumps into that block. This is valid C, but it hides the control flow. Place the label and themrb_raiseat the end of the function instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/string.c` around lines 3179 - 3184, In the function containing the total-length validation, move the size_error label and its mrb_raise call out of the if block to the function’s end. Keep the overflow condition and existing goto size_error control flow unchanged, with the label positioned after the normal execution path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/string.c`:
- Around line 3185-3188: Move the overlap-range check and offset calculation in
the surrounding string concatenation function before calling str_modify_cat.
Preserve the existing pointer-range condition and computed off value, then use
that offset after str_modify_cat so ptr is not dereferenced after shared-buffer
detachment.
---
Nitpick comments:
In `@mrbgems/mruby-string-ext/test/string.rb`:
- Around line 129-143: Update the setup for the e/f and g/h append scenarios in
the string tests so each base string is grown before creating its substring,
matching the spare-capacity preparation used around the earlier test group.
Preserve the existing substring offsets, append order, and assertions while
ensuring both cases exercise the in-place append path.
In `@src/string.c`:
- Around line 3179-3184: In the function containing the total-length validation,
move the size_error label and its mrb_raise call out of the if block to the
function’s end. Keep the overflow condition and existing goto size_error control
flow unchanged, with the label positioned after the normal execution path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c132258f-e510-440f-a1ad-34c4b2032b2b
📒 Files selected for processing (2)
mrbgems/mruby-string-ext/test/string.rbsrc/string.c
A substring longer than `RSTRING_EMBED_LEN_MAX` shares its parent's
buffer, and `str_share()` trims the parent's spare capacity away when it
does, so the next append to the parent reaches `str_unshare_buffer()` and
copies the whole buffer. A loop that appends to a string and takes a slice
of it does O(N) work per iteration and O(N^2) in total, even when the
slice is dropped immediately and nothing is retained.
```ruby
s = ""
n.times { s << "abcdefghijabcdefghij"; s[0, 30] }
```
| N | before | after | CRuby 4.0.6 |
| ---: | --- | --- | --- |
| 10000 | 0.55 s / 96.8 MB | 0.00 s / 3.8 MB | 0.04 s / 13.9 MB |
| 20000 | 2.71 s / 193.0 MB | 0.00 s / 3.8 MB | 0.04 s / 13.9 MB |
| 40000 | 8.40 s / 388.0 MB | 0.00 s / 4.5 MB | 0.04 s / 15.2 MB |
| 80000 | 35.19 s / 778.1 MB | 0.01 s / 5.5 MB | 0.05 s / 16.2 MB |
`dup` on its own takes the same path, and so does consuming a line buffer
with `buf = buf[(idx + 1)..-1]`, where the slice is most of the buffer:
16.90 s and 20.54 s before, 0.01 s and 0.03 s after, at N = 40000 and
100000 respectively. What is left grows linearly, the loop above costing
0.02 s / 5.6 MB at N = 80000 and 0.14 s / 23.0 MB at 640000.
An append only writes `[len, len + addlen)`, a region no other holder of
the buffer can see, so the copy is the conservative "never write into a
shared buffer" rule rather than something correctness needs. Give
`mrb_shared_string` a `reserved` watermark, the offset past the last byte
any sharer can see, stop trimming spare capacity on share, and route
`mrb_str_cat()` through `str_modify_cat()`, which appends in place when
the string ends at or above the watermark and the write stays inside the
allocation. Growing past the allocation still detaches, but capacity grows
geometrically, so those copies are amortized.
The watermark must only ever grow. `str_init_shared()` raises it to the
end of every string that joins the buffer, and an in-place append raises
it past the bytes it claims. Otherwise two strings sharing one buffer
would both write at the same offset and silently corrupt each other:
```ruby
s1 = "a" * 100
s1 << "z" * 100
s1 = s1[0, 100]
s2 = s1.dup
s1 << "x"
s2 << "y"
s1 # "a" * 100 + "x"
```
Whichever of the two appends first claims the bytes; the other no longer
ends at the watermark and copies the buffer as it did before.
Two consequences are worth stating. A `const char*` obtained from
`mrb_string_value_cstr()` can lose its NUL terminator when another string
sharing the buffer is appended to, because the in-place write can land on
that byte. Ruby level code is unaffected, since that function already
unshares when the terminator is missing, but it then returns a pointer
into a fresh allocation, so an extension caching the pointer across mruby
operations is left holding a stale one. And keeping spare capacity on
share means a retained `dup` or slice of a string built by appending can
pin up to twice the bytes it does today.
`MRB_STR_FSHARED` strings and growth through `mrb_str_resize()` still
detach in full. Neither shape measured here needs otherwise, and both are
room for a follow-up rather than part of this change.
35001f3 to
34e208b
Compare
|
Force-pushed. The test was rewritten; The first version of the test did not exercise the in-place path at all. Each group now grows its string by appending before it is shared. I confirmed the path The rewritten test also passes with On the other two review comments: the overlap offset one describes a defect that is on |
Summary
Taking a substring longer than
RSTRING_EMBED_LEN_MAX(27 bytes on a 64-bit build)shares the parent's buffer, and
str_share()trims the parent's spare capacity away whenit does. Every later append to the parent therefore has to unshare, which copies the
entire buffer. A loop that appends and looks at a slice does O(N) work per iteration and
O(N^2) in total, even when the slice is dropped immediately and nothing is retained.
An append only writes
[len, len + addlen), a region no other holder of the buffer cansee, so that copy is the conservative "never write into a shared buffer" rule rather than
something correctness needs. This adds a
reservedwatermark tomrb_shared_string, theoffset past the last byte any sharer can see, stops trimming spare capacity on share, and
routes
mrb_str_cat()through a newstr_modify_cat()that appends in place when thestring ends at or above the watermark and the write stays inside the allocation. Growing
past the allocation still detaches, but capacity grows geometrically, so those copies are
amortized instead of one per append.
The first of the two commits here is the one sent separately as #7038, and it is a
prerequisite rather than a cleanup.
mrb_str_cat()takeslenas asize_tand hands itto
mrb_int_add_overflow(), whose parameters aremrb_int, so a length aboveMRB_INT_MAXreaches the check already converted and negative. That is a latentout-of-bounds
memcpy()on master on its own, and with the watermark in place it wouldadditionally lower the watermark, which is the one way a co-sharer can be handed bytes
another string still reads. If #7038 lands first, this branch rebases onto it and the
commit disappears from here.
#7043 is a third change to the same handful of lines, independent of both this and #7038.
Whichever of the three lands first, I will rebase the others onto it.
Measurements
build_config/default.rb(-g -O3), gcc 13.3.0, Linux x86_64, 64-bit build, no boxingoverrides. Time and peak RSS from
/usr/bin/time, each run under a 6 GBRLIMIT_AS.CRuby 4.0.6 is there for scale, not as a target.
Dropping the slice at once, so nothing is retained and the live data is one string:
dupalone, with no slicing at all:Consuming a line buffer, the shape an incremental parser or a log scanner has. The slice
is most of the buffer here, so no threshold on slice size would cover it:
What is left grows linearly. The first loop:
Retaining every slice is quadratic in memory on master and in CRuby alike, because each
retained slice pins a full-length snapshot. It stops being quadratic here, since every
slice and the parent go on sharing one buffer:
Shapes with no shared buffer in them are unchanged, best of seven interleaved runs at
n = 2000000:
s << "abcdefghijabcdefghij"src[100, 200]a + "z""#{a}:#{a}"s.sub("quick", "slow")The invariant
The watermark must only ever grow.
str_init_shared()raises it to the end of everystring that joins the buffer, and an in-place append raises it past the bytes it claims.
Otherwise two strings sharing one buffer would both write at the same offset and silently
corrupt each other:
Whichever of the two appends first claims the bytes; the other no longer ends at the
watermark and copies the buffer as it did before. The same holds for a tail slice and its
parent, whose ends coincide: the first append wins and the second one detaches. Both
orders, the interior-slice case, appending a slice of a buffer to the string it came from,
and a frozen sharer are covered by the test added to
mruby-string-ext.Consequences
const char*obtained frommrb_string_value_cstr()can lose its NUL terminator whenanother string sharing the buffer is appended to, because the in-place write can land on
that byte. Ruby level code is unaffected:
mrb_string_value_cstr()already detects amissing terminator and unshares. The repair is not free, though, since it then returns a
pointer into a fresh allocation, so an extension caching the pointer across mruby
operations is left holding a stale pointer rather than merely an unterminated one.
dupor slice of a string built byappending can pin up to twice the bytes it does today. I could not get this to show up
in peak RSS, because the untouched tail of the allocation is never faulted in, but the
virtual size is real.
MRB_STR_FSHAREDstrings and growth throughmrb_str_resize()still detach in full.Neither shape measured here needs otherwise, and both are room for a follow-up rather than
part of this change.
Testing
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 thesame three on master with this branch reverted, so they are not from this change.