string.c: remember that a string was read as broken - #7144
Conversation
`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.
|
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)
📝 WalkthroughWalkthroughThe 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. ChangesString encoding state
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to 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
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_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 regexpengine. This keeps the other answer the same walk comes back with.
The mark
MRB_STR_BROKEN_ENCtakes the bit next toMRB_STR_VALID_ENC, with the fouraccessors 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 andthe other where it fails.
mrb_str_modify_keep_ascii()unmakes both. Every in-place write reachesthere.
str_modify_cat()unmakes both.str_replace()writes over the bytes without passing either of those, so ittakes both answers from the string it copies. This one is not an
optimization: without it a broken string that
replacemakes valid keepssaying it is broken.
mrb_str_times()carries both, guarded as described below.mrb_str_byte_subseq()carries neither, as it already carried neither ofthe 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.
Where the two marks part
*is the one place. A repetition of broken bytes reaches the same brokenplace 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_encodingA byte-indexed string is answered before either mark is read, so a broken
string that
force_encodingmakes binary still says it is valid, and one madeUTF-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_STRINGThere 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 answeringtrue 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 throughalready asks the same question of the same bytestwice, 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 inmrb_str_times(). Each one fails it. A third came up while writing this: withstr_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
full-debughostcxx_abidefaultEach 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