string.c: String#length stops at the end of a shared substring - #7096
Merged
Conversation
|
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 (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe UTF-8 length scanner now stops at the input buffer end. Tests cover character counts, substring boundaries, and truncated multibyte sequences. ChangesUTF-8 length handling
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 |
`mrb_utf8_strlen()` counts the characters of a byte range, and the inner
loop that walks a run of non-ASCII bytes tests only the byte it is about
to decode:
```c
while (NOASCII(*p)) {
p += mrb_utf8len(p, e);
len++;
}
```
Nothing there stops at `e`. The walk has held together because the byte at
`e` is almost always the terminating NUL, which is ASCII and ends the run.
A string built by `mrb_str_byte_subseq()` is the exception: a substring too
long to embed shares the parent's buffer and carries a length of its own,
so the byte at its end is the parent's next byte, and a multi-byte one
carries the walk straight past the end of the string being measured.
```ruby
s = ("あ" * 40)[0, 20]
s.bytesize # 60
s.length # was 80, CRuby: 20
s[20] # was "\xe3", CRuby: nil
("あ" * 40).byteslice(0, 59).length # was 82, CRuby: 21
```
Past `e`, `mrb_utf8len()` sees a negative `e - p` and returns 1 for every
byte, so each byte of the parent counts as one character until the walk
reaches the parent's terminator. Those bytes belong to the parent buffer,
which makes this a wrong answer rather than a fault, except over a buffer
that carries no terminator at all: `str_init_nofree()` keeps the caller's
pointer for a long `mrb_str_new_static()` string, and `sym_intern()` keeps
it for `mrb_intern_static()`, which `Symbol#length` then measures.
Two things hid it. A substring short enough to embed is copied and
terminated by `str_init_embed()`, so every short case answers correctly,
and the run stops at once when the parent's next byte is ASCII, so
`("aあ" * 30)[0, 20].length` is right while `("あ" * 40)[0, 20].length` is
not. `String#chars` walks the bytes itself instead of asking this function,
so it kept answering 20 for the string whose `length` said 80.
Test the bound before the byte. The outer loop of this same function, and
the walks in `chars2bytes()` and `bytes2chars()`, already ask `p < e` at
exactly this point. Counting a truncated sequence is untouched: a lead byte
with no room left still costs one, which is what CRuby answers too.
takumin
force-pushed
the
string-utf8-strlen-range
branch
from
August 12, 2026 03:29
fb8ee7b to
258ef78
Compare
This was referenced Aug 12, 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.
mrb_utf8_strlen()counts the characters of a byte range, and the innerloop that walks a run of non-ASCII bytes tests only the byte it is about
to decode:
Nothing there stops at
e. The walk has held together because the byte ateis almost always the terminating NUL, which is ASCII and ends the run.A string built by
mrb_str_byte_subseq()is the exception: a substring toolong to embed shares the parent's buffer and carries a length of its own,
so the byte at its end is the parent's next byte, and a multi-byte one
carries the walk straight past the end of the string being measured.
Past
e,mrb_utf8len()sees a negativee - pand returns 1 for everybyte, so each byte of the parent counts as one character until the walk
reaches the parent's terminator. Those bytes belong to the parent buffer,
which makes this a wrong answer rather than a fault, except over a buffer
that carries no terminator at all:
str_init_nofree()keeps the caller'spointer for a long
mrb_str_new_static()string, andsym_intern()keepsit for
mrb_intern_static(), whichSymbol#lengththen measures.Two things hid it. A substring short enough to embed is copied and
terminated by
str_init_embed(), so every short case answers correctly,and the run stops at once when the parent's next byte is ASCII, so
("aあ" * 30)[0, 20].lengthis right while("あ" * 40)[0, 20].lengthisnot.
String#charswalks the bytes itself instead of asking thisfunction, so it kept answering 20 for the string whose
lengthsaid 80.The change
One bound test in the inner loop of
mrb_utf8_strlen(), so the walk asksp < ebefore reading the byte. The outer loop of the same function, andthe walks in
chars2bytes()andbytes2chars(), already ask it atexactly this point.
Nothing else changes. Counting a truncated sequence is untouched: a lead
byte with no room left still costs one, so a cut character answers as it
did, which is also what CRuby answers.
Related, not required
mrb_utf8len()reads only the top five bits of the lead byte, so it takesan overlong sequence, a surrogate, or a value above U+10FFFF as a whole
character and counts one where CRuby counts a byte each. #7093 is about
that, and the two changes are independent: this one bounds the walk, that
one decides what a sequence inside the bound is worth. They touch
neighbouring functions in the same file and neither waits on the other.
Testing
rake testis green onfull-corewithMRB_UTF8_STRING, gcc onx86_64-linux: 2231 asserts, no failures.mrb_utf8_strlen()sits inside#ifdef MRB_UTF8_STRING, so a build without it is not compiled againstthis code at all.
The new test fails without the change:
It covers a shared substring starting at the parent's first byte and one
starting inside it, a four-byte character, and a substring cut in the
middle of a character. It also pins the three that were right all along,
so the paths this does not touch cannot move silently: a substring short
enough to embed, one reaching the parent's terminator, and one whose run
of non-ASCII bytes ends on an ASCII one. Checked against CRuby 4.0.6.
Summary by CodeRabbit
Bug Fixes
Tests