Skip to content

string.c: remember that a string was read as broken - #7144

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:string-remember-broken-encoding
Aug 14, 2026
Merged

string.c: remember that a string was read as broken#7144
matz merged 3 commits into
mruby:masterfrom
takumin:string-remember-broken-encoding

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

mrb_str_valid_encoding_p() marks a string whose bytes it has read as UTF-8,
and answers a later question off that mark rather than by another walk. Only
the answer of true is kept that way. A string holding a byte that spells no
character leaves nothing behind, so every question after the first walks it
again, however many times it is asked: once per needle in str_index_str(),
once per call at the entry to split, once per subject handed to the regexp
engine. This keeps the other answer the same walk comes back with.

The mark

MRB_STR_BROKEN_ENC takes the bit next to MRB_STR_VALID_ENC, with the four
accessors beside the ones it mirrors. The two marks stand or fall together,
since a write to the bytes unmakes either answer:

  • mrb_str_valid_encoding_p() leaves one where the walk reaches the end and
    the other where it fails.
  • mrb_str_modify_keep_ascii() unmakes both. Every in-place write reaches
    there.
  • The shared appending path in str_modify_cat() unmakes both.
  • str_replace() writes over the bytes without passing either of those, so it
    takes both answers from the string it copies. This one is not an
    optimization: without it a broken string that replace makes valid keeps
    saying it is broken.
  • mrb_str_times() carries both, guarded as described below.
  • mrb_str_byte_subseq() carries neither, as it already carried neither of
    the answer it had. A cut leaves a character in pieces, and it also cuts away
    the piece that spelled none, so a subrange inherits validity in neither
    direction. The comment there said this about one mark and now says it about
    both.
"a\x80".valid_encoding?                  #=> false
"a\x80".byteslice(0, 1).valid_encoding?  #=> true
"あ".valid_encoding?                     #=> true
"あ".byteslice(0, 1).valid_encoding?     #=> false

Where the two marks part

* is the one place. A repetition of broken bytes reaches the same broken
place the first copy does, so the result is broken too. Nought copies keep
none of the bytes, though, and an empty string is not broken whatever it was
made from, so the mark travels only where something was copied. The mark
beside it needs no such guard, since an empty string is valid either way.

force_encoding

A byte-indexed string is answered before either mark is read, so a broken
string that force_encoding makes binary still says it is valid, and one made
UTF-8 again meets the mark it left. The bytes did not change, only what they
are taken to be, so that is the answer to give.

Builds without MRB_UTF8_STRING

There is no encoding for a string to break, and the mark is nothing there, as
the one beside it already is. mrb_str_valid_encoding_p() keeps answering
true for every string.

Tests

Nothing a program can see changes, so the tests here are what stops a mark
from outliving the bytes it was left for. String#valid_encoding? survives what the string goes through already asks the same question of the same bytes
twice, once with the answer remembered and once without, but every base it
started from was valid and every change it made broke them, so it only ever
asked about a mark left by an answer of true. It now starts from broken bases
as well, and makes changes that repair them.

The test commit comes first and passes on its own, since the answers it asks
for are what they were. What it pins is the mark. Two mutations show that it
reaches the code: dropping the line that unmakes the mark in
mrb_str_modify_keep_ascii(), and dropping the guard on nought copies in
mrb_str_times(). Each one fails it. A third came up while writing this: with
str_replace() left out of the second commit, the test failed there too,
which is why that line sits with the rest rather than with *.

Testing

build UTF-8 mrbtest bintest
full-debug yes 2278, KO 0
host yes 2278, KO 0 116, KO 0
cxx_abi yes 2277, KO 0
default no 2065, KO 0 105, KO 0

Each of the three commits was built and run on its own against the last two of
those, and all three are green.

Summary by CodeRabbit

  • Bug Fixes
    • Improved UTF-8 encoding validation for strings by caching invalid results.
    • Ensured encoding status is correctly updated after string mutations, slicing, replacement, and repetition.
    • Prevented zero-length repetitions and substrings from incorrectly inheriting invalid encoding status.
  • Tests
    • Expanded coverage for valid, malformed UTF-8, binary, and mutation scenarios.

`String#valid_encoding?` is answered off a mark the string carries, so the
test that pins it walks a string through every change that has to forget the
mark and every copy that has to carry it along. Each base it starts from is
valid, and the changes it makes all break them, so the pairs only ever ask
about a mark left by an answer of true.

Start from broken bases as well, and add changes that repair them: appending
the byte a truncated character is missing, `replace`, `clear`, nought copies,
and `b`. A string is then asked again after a change that unmakes either
answer it could be holding, and a copy is asked whether the answer travelled
with the bytes in either direction.

The answers themselves are what they were, so this passes as it stands. What
it pins is the mark, which the commits after this one leave behind.
`mrb_str_valid_encoding_p()` marks a string whose bytes it has read as UTF-8,
so a later question about the same string is answered off the mark rather than
by another walk. Only the answer of true is kept that way. A string holding a
byte that spells no character leaves nothing behind, and every question after
the first walks it again, however many times it is asked: once per needle in
`str_index_str()`, once per call at the entry to `split`, once per subject
handed to the regexp engine.

Keep the other answer the same walk comes back with. `MRB_STR_BROKEN_ENC`
takes the bit next to `MRB_STR_VALID_ENC`, and the two now stand or fall
together, since a write to the bytes unmakes either answer.

Leaving the mark and unmaking it are one change and not two. A mark that
outlives the bytes it was left for is a wrong answer, not a slow one, so the
three places that already unmake `MRB_STR_VALID_ENC` take the new mark with
them: `mrb_str_modify_keep_ascii()`, which every in-place write reaches, the
shared appending path in `str_modify_cat()`, and `str_replace()`, which writes
over the bytes without passing either of those and so has to take its answer
from the string it copies.

`mrb_str_byte_subseq()` keeps taking neither mark, and what stood there as a
statement about one of them is now one about both. A cut leaves a character in
pieces, and it also cuts away the piece that spelled none, so a subrange
inherits validity in neither direction:

```ruby
"a\x80".valid_encoding?                  #=> false
"a\x80".byteslice(0, 1).valid_encoding?  #=> true
"あ".valid_encoding?                     #=> true
"あ".byteslice(0, 1).valid_encoding?     #=> false
```

A byte-indexed string is answered before either mark is read, so a broken
string that `force_encoding` makes binary still says it is valid, and one made
UTF-8 again meets the mark it left. That is the answer to give: the bytes did
not change, only what they are taken to be.

Nothing a program can see changes. A build without `MRB_UTF8_STRING` reads no
encoding for a string to break, and the mark is nothing there, as the one
beside it already is.
A repetition is built out of the bytes it repeats, so it reaches the same
broken place the first copy does and is broken too. The commit before this
leaves the answer on the string it was asked about, and `*` already hands
`MRB_STR_VALID_ENC` to the string it builds, so hand it this one as well and
a repetition of a string already read is not read again.

Nought copies keep none of the bytes, though, and an empty string is not
broken whatever it was made from, so the mark travels only where something was
copied. The mark beside it needs no such guard, since an empty string is valid
either way.

The string is built here rather than written over, so nothing of its own can
outlive this: unlike `str_replace()`, there is no earlier answer to unmake.
That makes this the one place the mark is carried for speed alone, and reverting
it costs a walk and nothing else.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 87b3857f-4224-42ca-9a71-37443710c61a

📥 Commits

Reviewing files that changed from the base of the PR and between 8116d5a and da0f123.

📒 Files selected for processing (3)
  • include/mruby/string.h
  • mrbgems/mruby-encoding/test/string.rb
  • src/string.c

📝 Walkthrough

Walkthrough

The PR adds cached broken-encoding state for UTF-8 strings. Validation records invalid scans, mutations clear cached state, and selected derived-string operations propagate it. Tests cover mutation, copying, slicing, replacement, repetition, and malformed inputs.

Changes

String encoding state

Layer / File(s) Summary
Broken-encoding state contract
include/mruby/string.h
Adds the public broken-encoding flag and UTF-8 helpers. Non-UTF-8 builds use disabled fallback definitions.
Validation caching and mutation invalidation
src/string.c
Validation returns cached invalid results and records invalid scans. Content modification clears the cached state.
Derived-string propagation and coverage
src/string.c, mrbgems/mruby-encoding/test/string.rb
Replacement and non-empty repetition copy broken state. Substrings do not inherit it. Tests cover these rules and additional string mutations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to da0f1

The PR remembers broken string encoding results and invalidates them across relevant mutations; no actionable merge-blocking risk remains, so it is merge-ready after normal checks.

Possibly related PRs

  • mruby/mruby#7126: Uses related UTF-8 validity handling for invalid string subjects.
  • mruby/mruby#7102: Introduces related mrb_str_valid_encoding_p validation behavior.
  • mruby/mruby#7142: Modifies encoding behavior controlled by MRB_UTF8_STRING.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: caching that a string has broken encoding.
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.
✨ 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.

@matz
matz merged commit ff326d2 into mruby:master Aug 14, 2026
20 of 21 checks passed
@takumin
takumin deleted the string-remember-broken-encoding branch August 14, 2026 03:56
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