test: growing a shared string detaches its buffer - #7167
Conversation
`mrb_str_cat()` appends into a buffer it shares, because an append only
writes `[len, len + addlen)`, which no other sharer can see. Growth through
`mrb_str_resize()` looks like the same opportunity and is not: that function
hands the buffer back to its caller, and the callers write from offset 0.
`String#insert` memmoves from the insertion index, `String#prepend` moves the
whole content up, `String#succ!` writes over the string from the front, and
`String#bytesplice` writes from `idx1`. Most of them call `mrb_str_modify()`
before `mrb_str_resize()`, so they detach for their own reasons. `IO#sysread`
and `BasicSocket#recv` do not: both resize a buffer handed in from Ruby and
then read into it from offset 0, `io.c` calling `mrb_str_modify()` only on the
branch where the length already matches and no resize happens.
Nothing in the suite covered any of this. Letting `mrb_str_resize()` claim
spare capacity in a shared buffer the way `mrb_str_cat()` does, changing
nothing else, left all 2049 tests passing while a `sysread` into a shared
buffer of a different length wrote through to a string that was never touched.
Dropping the redundant `mrb_str_modify()` calls on top of it does the same to
a = "a" * 100
a << "z" * 100
b = a[0, 150]
a.insert(0, "XXXX")
b # "XXXXaaaa..." instead of "a" * 100 + "z" * 50
The `IO#sysread` test fails on the first of those, the `insert` at 0, the
`prepend` and the `bytesplice` cases on the second. The remaining cases assert
the same invariant on shapes that cannot currently distinguish the two
behaviours, and are marked as such.
|
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 (3)
📝 WalkthroughWalkthroughAdds regression tests for shared-buffer string growth, ChangesShared Buffer Regression Tests
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This PR adds regression coverage without changing product behavior; after normal checks and review, no actionable merge-blocking risk remains. 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 |
mrb_str_cat()appends into a buffer it shares with other strings, because an append only writes[len, len + addlen), a range no other sharer can see. Growth throughmrb_str_resize()looks like the same opportunity, and it is not. Nothing in the suite said so; these tests do.mrb_str_resize()is a public API that hands the buffer back to its caller and lets it write wherever it likes, and its callers write from the front:String#insertmemmoves from the insertion index,String#prependmoves the whole content up from 0,String#succ!writes over the string from 0,String#bytesplicewrites fromidx1, and the callers in mruby-pack and mruby-io fill their buffers from offset 0. Keeping the buffer shared across the growth lets any of them overwrite bytes another string is still reading.Most of those call
mrb_str_modify()themselves before resizing, so they detach for their own reasons.IO#sysreadandBasicSocket#recvdo not: both resize a buffer supplied from Ruby and then read into it from offset 0,io.ccallingmrb_str_modify()only on the branch where the length already matches and no resize happens. So the invariant is load-bearing today, and untested.Two variants of the optimisation were built to check that:
str_modify_cat()'s logic inmrb_str_resize(), nothing else changed.mrb_str_modify()inString#insert,String#prependandString#bytesplice.Both pass the pre-existing suite with
KO: 0. Variant A silently rewrites a string throughIO#sysread:and variant B additionally does it through
String#insert(0, ...)andString#prepend:With the tests in this PR, variant A fails on
IO#sysread into a shared buffer(KO: 1) and variant B fails on that plusString growth on a shared bufferandString#bytesplice on a shared buffer(KO: 3).The optimisation was measured before being rejected: on
s = ""; N.times { s.insert(-1, "...20 bytes..."); s[0, 30] }it is indistinguishable from master over 9 repetitions per point, and both remain quadratic.mrb_str_resize()sizes the buffer to exactly the requested length, so there is none of the geometric growth that makesmrb_str_cat()'s in-place append amortize.Some assertions here pin the invariant on shapes that cannot currently distinguish the two behaviours:
insertat the end writes above the other sharer, andsucc!writes a terminator in place before it grows. They are kept as contract and marked as such in the comments.Tests only, no behaviour change.
rake -m test: mrbtestTotal: 2078, OK: 2049, KO: 0, Crash: 0, Warning: 0, Skip: 29, up from 2075 by the three new blocks; bintestTotal: 105, OK: 105, KO: 0.Summary by CodeRabbit