Skip to content

string.c: append into a shared buffer instead of copying it - #7039

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:string-shared-append-inplace
Aug 9, 2026
Merged

string.c: append into a shared buffer instead of copying it#7039
matz merged 2 commits into
mruby:masterfrom
takumin:string-shared-append-inplace

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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 when
it 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 can
see, so that copy is the conservative "never write into a shared buffer" rule rather than
something correctness needs. This adds a reserved watermark to mrb_shared_string, the
offset past the last byte any sharer can see, stops trimming spare capacity on share, and
routes mrb_str_cat() through a new str_modify_cat() that 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 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() takes len as a size_t and hands it
to mrb_int_add_overflow(), whose parameters are mrb_int, so a length above
MRB_INT_MAX reaches the check already converted and negative. That is a latent
out-of-bounds memcpy() on master on its own, and with the watermark in place it would
additionally 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 boxing
overrides. Time and peak RSS from /usr/bin/time, each run under a 6 GB RLIMIT_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:

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 alone, with no slicing at all:

s = ""
n.times { s << "0123456789012345678901234567890123456789"; t = s.dup }
N before after CRuby 4.0.6
5000 0.25 s / 91.5 MB 0.00 s / 3.8 MB 0.39 s / 61.0 MB
10000 1.02 s / 191.4 MB 0.00 s / 3.8 MB 1.04 s / 61.3 MB
20000 3.82 s / 382.4 MB 0.00 s / 4.5 MB 2.86 s / 61.5 MB
40000 16.90 s / 772.6 MB 0.01 s / 5.4 MB 7.28 s / 61.5 MB

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:

buf = ""
n.times do |i|
  buf << "hello world this is a line of text\n"
  if (i % 3) == 0
    idx = buf.index("\n")
    buf = buf[(idx + 1)..-1]
  end
end
N before after CRuby 4.0.6
25000 1.10 s / 111.8 MB 0.00 s / 5.0 MB 1.17 s / 59.9 MB
50000 5.16 s / 233.1 MB 0.01 s / 5.7 MB 3.53 s / 61.8 MB
100000 20.54 s / 472.4 MB 0.03 s / 6.7 MB 8.71 s / 55.1 MB

What is left grows linearly. The first loop:

N after
80000 0.02 s / 5.6 MB
160000 0.05 s / 8.2 MB
320000 0.10 s / 13.3 MB
640000 0.14 s / 23.0 MB

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:

subs = []
s = ""
n.times { s << "0123456789012345678901234567890123456789"; subs << s[-30, 30] }
N before after CRuby 4.0.6
2000 0.05 s / 80.2 MB 0.00 s / 3.8 MB 0.13 s / 97.3 MB
5000 0.41 s / 486.7 MB 0.00 s / 4.0 MB 0.36 s / 507.7 MB
10000 1.25 s / 1927.0 MB 0.00 s / 4.5 MB 1.04 s / 1947.7 MB

Shapes with no shared buffer in them are unchanged, best of seven interleaved runs at
n = 2000000:

shape before after
s << "abcdefghijabcdefghij" 0.16 s 0.16 s
src[100, 200] 0.18 s 0.19 s
a + "z" 0.31 s 0.32 s
"#{a}:#{a}" 0.29 s 0.29 s
s.sub("quick", "slow") 2.97 s 3.00 s

The invariant

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:

s1 = "a" * 100
s1 << "z" * 100
s1 = s1[0, 100]
s2 = s1.dup
s1 << "x"
s2 << "y"
s1              # "a" * 100 + "x"
s2              # "a" * 100 + "y"

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

  • 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: mrb_string_value_cstr() already detects a
    missing 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.
  • 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. 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_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.

Testing

All green, KO: 0 and Crash: 0 everywhere:

  • rake test with build_config/default.rb
  • MRUBY_CONFIG=asan rake test (address,undefined)
  • MRUBY_CONFIG=ci/gcc-clang rake test, which covers MRB_GC_STRESS with
    MRB_USE_DEBUG_HOOK, MRB_GC_FIXED_ARENA, and the C++ ABI build
  • MRB_UTF8_STRING
  • MRB_INT32 with MRB_WORD_BOXING and MRB_UTF8_STRING
  • MRB_NAN_BOXING

The MRB_NAN_BOXING build fails three mruby-string-bitops assertions, and fails the
same three on master with this branch reverted, so they are not from this change.

`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.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change tracks visible ranges in shared string buffers, reuses spare capacity for safe appends, validates append sizes, and adds regression tests for sharing scenarios.

Changes

Shared-buffer string append handling

Layer / File(s) Summary
Track shared-buffer boundaries
src/string.c
Shared strings now record a reserved boundary. Sharing updates this boundary and retains spare capacity.
Select safe append modification
src/string.c
mrb_str_cat validates lengths and overflow before modification. str_modify_cat reuses shared capacity when safe and otherwise detaches.
Validate shared-buffer concat behavior
mrbgems/mruby-string-ext/test/string.rb
Tests cover independent appends, overlapping slices, interior slices, self-appending slices, and frozen sharers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: matz

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: enabling append operations directly in shared string buffers.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
mrbgems/mruby-string-ext/test/string.rb (1)

129-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

These two groups may not reach the in-place append path.

Groups at lines 131-136 and 138-143 build e and g with "b" * 100 and "c" * 100. That allocation can have capacity equal to the length, with no spare bytes. str_modify_cat then 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 value

Move the size_error: label out of the if body.

The label sits inside the if block. The goto size_error; at line 3193 jumps into that block. This is valid C, but it hides the control flow. Place the label and the mrb_raise at 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9a3d566 and 35001f3.

📒 Files selected for processing (2)
  • mrbgems/mruby-string-ext/test/string.rb
  • src/string.c

Comment thread src/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.
@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed. The test was rewritten; src/string.c is unchanged.

The first version of the test did not exercise the in-place path at all. String#*
allocates with capa == len, so a string built that way has no spare capacity and every
append to a slice of it copies the buffer as before. Three of the five groups were
therefore asserting master's behaviour, two of them being the groups meant to show that
the first append claims the bytes.

Each group now grows its string by appending before it is shared. I confirmed the path
with a temporary fprintf() in str_modify_cat(), and all seven cases reach it:

INPLACE off=0   len=200 addlen=1     parent appends, interior slice unaffected
INPLACE off=0   len=200 addlen=1     dup, parent appends first
INPLACE off=0   len=200 addlen=1     dup, copy appends first
INPLACE off=170 len=30  addlen=1     tail slice and parent end at the same offset
INPLACE off=0   len=80  addlen=40    append and slice in a loop (50 iterations)
INPLACE off=0   len=200 addlen=40    append a slice of the same buffer
INPLACE off=0   len=200 addlen=1     frozen sharer alongside

The rewritten test also passes with src/string.c reverted to master, so it does not
depend on this change to hold.

On the other two review comments: the overlap offset one describes a defect that is on
master rather than something this branch introduces, so it is #7043 now. The
size_error: label placement is master's own, and I left it alone.

@matz
matz merged commit 9360b3f into mruby:master Aug 9, 2026
21 checks passed
@takumin
takumin deleted the string-shared-append-inplace branch August 9, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants