Skip to content

common: Fix surrogate pair stepping in TextInputModel delete methods - #191868

Closed
cbracken wants to merge 1 commit into
flutter:masterfrom
cbracken:fix-surrogate-pair-stepping
Closed

cbracken wants to merge 1 commit into
flutter:masterfrom
cbracken:fix-surrogate-pair-stepping

Conversation

@cbracken

@cbracken cbracken commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

Fixes surrogate pair stepping in DeleteSurrounding, Delete, and Backspace. Ironically, I landed a very similar change for iOS almost a decade ago: flutter-team-archive/engine#3590.

Walking forward over the characters to delete was measuring each one against text_.at(start) rather than text_.at(end), so after the first step we kept re-reading the character we started from. That over-deletes when the first character is a surrogate pair, and splits the pair when a later one is, leaving an unpaired surrogate in text_ that GetText then can't convert, resulting in a range error.

This also limits both ends of the deletion to the editable range. We need to protect against a surrogate pair that straddles the boundary.

We now stop the walk at the boundary rather than step past it. start is clamped on entry because the selection is not required to sit inside the composing range either. SetComposingRange accepts a cursor offset that could place the caret beyond the range's end. Without this, start could exceed end and underflow deleted_length, which erases the rest of the buffer and wraps the composing range, or index out of bounds and blow up.

Extracted helpers rather than copy-pasting even more than we already had.

Spotted which working on: #190704

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Aug 27, 2026
@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems engine flutter/engine related. See also e: labels. labels Aug 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates TextInputModel::DeleteSurrounding to handle surrogate pairs and prevent out-of-bounds indexing when deleting text, and adds corresponding unit tests. The review feedback suggests preventing surrogate pairs from being split at the end of the editable range by checking the remaining space before stepping forward, and recommends adding a test case to verify deletion behavior at the cursor when a surrogate pair straddles the end of the composing range.

@cbracken
cbracken force-pushed the fix-surrogate-pair-stepping branch from 7e0ff6b to c7589ac Compare August 27, 2026 10:25
@cbracken

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the helper functions NextCharacterBoundary and PreviousCharacterBoundary to safely navigate UTF-16 surrogate pairs and boundaries, and refactors DeleteSurrounding to use them. It also adds several unit tests covering edge cases with surrogate pairs and composing ranges. The review feedback suggests refining both helper functions to explicitly verify that leading and trailing surrogates form valid pairs within the specified limits, preventing unpaired surrogates from being incorrectly handled.

@cbracken
cbracken force-pushed the fix-surrogate-pair-stepping branch from c7589ac to 016c37b Compare August 27, 2026 10:47
Fixes surrogate pair stepping in `DeleteSurrounding`, `Delete`, and
`Backspace`. This is not my first rodeo: flutter-team-archive/engine#3590.

Walking forward over the characters to delete was measuring each one
against `text_.at(start)` rather than `text_.at(end)`, so after the
first step we kept re-reading the character we started from. That
over-deletes when the first character is a surrogate pair, and splits
the pair when a later one is, leaving an unpaired surrogate in `text_`
that `GetText` then can't convert, resulting in a range error.

This also limits both ends of the deletion to the editable range. We
need to protect against a surrogate pair that straddles the boundary.

We now stop the walk at the boundary rather than step past it. `start`
is clamped on entry because the selection is not required to sit inside
the composing range either. `SetComposingRange` accepts a cursor offset
that could place the caret beyond the range's end. Without this, `start`
could exceed `end` and underflow `deleted_length`, which erases the rest
of the buffer and wraps the composing range, or index out of bounds and
blow up.

Extracted helpers rather than copy-pasting even more than we already
had.

Spotted which working on: flutter#190704
@cbracken
cbracken force-pushed the fix-surrogate-pair-stepping branch from 016c37b to 500fc64 Compare August 27, 2026 11:05
@cbracken cbracken changed the title common: Fix surrogate pair stepping in TextInputModel::DeleteSurrounding common: Fix surrogate pair stepping in TextInputModel delete methods Aug 27, 2026

@LongCatIsLooong LongCatIsLooong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we can assume the framework (including the app developer) and the system (including the IME) never break surrogate pairs (not even temporarily). If they do then we should assert.

return (code_point & 0xFFFFFC00) == 0xDC00;
}

// Returns the position one character after |pos|, or |limit| if a whole

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

terminology nit, character -> codepoint

// stepped over alone rather than swallowing whatever follows it.
size_t NextCharacterBoundary(const std::u16string& text,
size_t pos,
size_t limit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

limit does not seem necessary. We should never break a surrogate pair given the string is not malformed.

@cbracken cbracken Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

limit is about the bounds of the editable range, not just surrogate pairs. Take a look at DeleteEndComposing and related reversed and backspace tests as an example of where we're using this in place of the previous position != editable_range().start() etc. This is reducing copy-paste (and potentially forgetting/being incosistent) about those checks.

Note that in those tests, the string is all well-formed but they would fail if we removed limit.

return pos;
}
size_t step = IsTrailingSurrogate(text.at(pos - 1)) && pos >= 2 &&
IsLeadingSurrogate(text.at(pos - 2))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If pos - 1 is a trailing surrogate then pos - 2 if exists, must be a leading surrogate otherwise the string is just malformed. We should not check that and should probably assert (however it would be better if the framework asserts that).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Same with the previous helper function).

@cbracken cbracken Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this comes back to the more general question of whether it's safe to assume that the string is well-formed. We don't assert that anywhere in FlutterTextInputPlugin (and I suspect not Windows or Linux either). Asserting that in the framework is insufficient since the API boundary is the engine boundary, not the framework, I talk a little more about this below and I know that's a bit of a (policy-wise) controversial position.

But even if we claim we don't support alternatives built on dart:ui, from a technical point of view, its far, far safer and more future-proof to treat everything outside the engine/embedder as a black box, and not rely on internal implementation details behind a half dozen layers of API boundaries.

EXPECT_STREQ(model->GetText().c_str(), "😄🙃🧐");
}

TEST(TextInputModel, DeleteSplitPairAtComposingEnd) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think this is a valid use case. It should be safe to assume composing ranges can only contain valid unicode strings.

@cbracken cbracken Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This test's string is valid unicode. "a😄bcd" is well-formed. This is defending against the boundary being in the middle of a composing range.


auto end = start;
for (int i = 0; i < count && end != max_pos; i++) {
end += IsLeadingSurrogate(text_.at(start)) ? 2 : 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I vaguely remember this is already fixed by a different PR? Maybe it wasn't merged?

@cbracken cbracken Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I did a look through the commit history for lands/reverts and don't see anything, but maybe it was a PR that was closed unmerged? I didn't check other embedders though (well other than my own one from a decade ago in iOS 😂)

@cbracken

cbracken commented Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

I think we can assume the framework (including the app developer) and the system (including the IME) never break surrogate pairs (not even temporarily). If they do then we should assert.

I don't think this is correct.

  1. We can't trust IME input. Even if we trust Apple's IMEs, I wrote TextInputModel for Windows (initially) then ported to macOS and Linux embedders. This string of fixes started with [macOS] SIGABRT in TextInputModel::UpdateComposingText: unguarded u16string::replace throws std::out_of_range when a third-party Chinese IME sends setMarkedText during input-source switch #190704, which was triggered by a third-party (TenCent) IME and a desynced composing range. We've seen a ton of issues with Sogou on Windows too and we know we can't fully trust IMEs.
  2. In general, I don't think we can assume surrogate pairs will never be broken. [ios]: Trying to delete emoji crashes the app and throws Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid JSON message, encoding failed' error #106933 is an example of when it happened (fixed in Fix an issue that deleting an emoji may crash the app flutter-team-archive/engine#34508) though this was due to an
  3. We take input from both the IME and the framework and there's no guarantee that the two are in sync. We get direct input from the user via the IME, but that may be racing against a programmatically-triggered edit in the framework (e.g. form input validators). I mentioned this here
    // The framework computes these ranges against its own copy of the text, which may be a different
    // revision than the text it sends here, and the ranges are dropped entirely by |SetText| if they
    // don't fit.
    //
    // Clamp them to the incoming text so a stale offset degrades the caret position rather than
    // discarding the text update.
    . There are ways we can make this much better with the threads merged now, but it's still not safe to assume they're in sync today.
  4. This is me being a bit nit-picky/old-fashioned about how much we trust channel input, but the API boundary isn't the framework, it's dart:ui. Generally we should treat the other side of dart:ui as a black box or we risk embedding assumptions into the engine/embedders that become wrong over time (plus just good interface/implementation separation practice).

I do strongly agree we should be using more asserts, because there's huge room for (and lots of history of) programming errors on our part. But I don't think that's sufficient. There's a whole class of issues with the data coming from IMEs (definitely) and over the channel (out of paranoia) that are runtime issues we need to validate at runtime.

Right now we do zero validation at all on the incoming bytes at the embedder entrypoint here (lines 938-942 if the link doesn't work). We also never check anywhere in our code that range endpoints don't fall in the middle of a surrogate pair and we have had a long history of being wrong about that, admittedly typically due to our own bugs.

This got pretty long but I think a reasonable TL;DR would be something like "yes we should assert more, but that's not enough. We need runtime checks". I could easily be convinced we should push those runtime checks up into the platform-specific embedders right at the input boundaries from the IME and channel, but the whole point of TextInputModel was that I was tired of rewriting this code over and over. (In hindsight, I'm still not sure if it was the right choice)

@LongCatIsLooong

LongCatIsLooong commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Yeah in theory it is possible for IMEs to send us malformed strings (which fortunately is unlikely. A selection / composing region that fails to account for surrogate pairs tho is a bit more likely). But when that happens I believe even if the text input plugin doesn't crash immediately (because of failing to encode the update into a MethodCall), there's no existing mechanism for the text input channel protocol to handle this "correctly" so we'll most likely just silently ignore the update that contains the malformed string, and that may result in unexpected behaviors down the road for the client (the framework text field).

So while attempting to handle lone surrogates gracefully in places like delete doesn't hurt, I suspect most of them are going to be dead code because it would run into crashers way before. I think it would be far easier to have assertions at dart:ui / embedder input boundaries like you suggested and enjoy the peace of mind of not having to deal with malformed strings in the internal implementation everywhere.

  1. We can't trust IME input. Even if we trust Apple's IMEs, I wrote TextInputModel for Windows (initially) then ported to macOS and Linux embedders. This string of fixes started with [macOS] SIGABRT in TextInputModel::UpdateComposingText: unguarded u16string::replace throws std::out_of_range when a third-party Chinese IME sends setMarkedText during input-source switch #190704, which was triggered by a third-party (TenCent) IME and a desynced composing range. We've seen a ton of issues with Sogou on Windows too and we know we can't fully trust IMEs.

Does that mean the macOS IME protocol is racy by design (that can be true but I'm hoping that's not the case)? Or it is just our text input plugins have a bug somewhere (like failing to inform the system that the text's been changed by a different party so the previous setMarkedText command must be discarded)?

  1. In general, I don't think we can assume surrogate pairs will never be broken. [ios]: Trying to delete emoji crashes the app and throws Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid JSON message, encoding failed' error #106933 is an example of when it happened (fixed in Fix an issue that deleting an emoji may crash the app flutter-team-archive/engine#34508) though this was due to an

(Looks like the sentence is unfinished here). Ah I remember this one. I didn't review it and the patch itself doesn't really explain why, so I don't fully understand what the root cause was for this bug. I suspect that was a (now fixed) bug in the [NSString rangeOfComposedCharacterSequenceAtIndex:] implementation which fails to account for emoji sequences (but I can't reproduce in my swift playground now. I suspect that if you delete the fix the tests should still pass on a newer version of iOS). In any case, that seems to be a case where our implementation needs to be fixed (even if it was a rangeOfComposedCharacterSequenceAtIndex: problem we would still need to workaround that in our codebase) such that it doesn't produce malformed strings, instead of one showing that IME can send us malformed strings (in theory that can happen but IMEs especially CJK ones usually need to inspect the string themselves to understand what's inside for predictive text / turning alphabets into ideographs so if it had a malformed string the IME would have corrected that or crashed when evaluating).

  1. We take input from both the IME and the framework and there's no guarantee that the two are in sync. We get direct input from the user via the IME, but that may be racing against a programmatically-triggered edit in the framework (e.g. form input validators). I mentioned this here
    // The framework computes these ranges against its own copy of the text, which may be a different
    // revision than the text it sends here, and the ranges are dropped entirely by |SetText| if they
    // don't fit.
    //
    // Clamp them to the incoming text so a stale offset degrades the caret position rather than
    // discarding the text update.

    . There are ways we can make this much better with the threads merged now, but it's still not safe to assume they're in sync today.

Yes there are a lot of different ways the app developer can change the input text (I think you probably meant input formatters), but the text input plugin communication protocol always communicates TextEditingValues (which is the combination of the text, the selection and the composing range) as a whole, it never updates the text and the composing region in separate calls. So I don't think the async communication is really the root cause of such races (and if this kind of races is rooted in the macOS/iOS IME protocol, UITextView/NSTextField would have to deal with this problem too). I actually don't recalling seeing a real world case where the text input plugin received an outdated command from the IME.

  1. This is me being a bit nit-picky/old-fashioned about how much we trust channel input, but the API boundary isn't the framework, it's dart:ui. Generally we should treat the other side of dart:ui as a black box or we risk embedding assumptions into the engine/embedders that become wrong over time (plus just good interface/implementation separation practice).

Fair point but in practice the text input plugin always sends the updated TextEditingValue to the framework (or whoever the client is) immediately upon receiving a new edit. The communication currently uses a different encoding (utf8) and a malformed string would cause NSJSONSerialization.jsonObject to throw and as previously mentioned we'll have problem turning that into a JSON MethodCall.

we should push those runtime checks up into the platform-specific embedders right at the input boundaries from the IME and channel

Yeah that would be ideal. I think it already crashes when the framework sends the embedder a malformed string on macOS and iOS.

@cbracken cbracken closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems CICD Run CI/CD engine flutter/engine related. See also e: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants