-
Notifications
You must be signed in to change notification settings - Fork 4.6k
fix(sandbox): keep split UTF-8 characters intact across PTY output windows #4724
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a9e76be
a4d6d32
eb683f3
65a333b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,33 @@ | |
| from .pty_types import truncate_text_by_tokens | ||
|
|
||
|
|
||
| def _incomplete_utf8_suffix_length(data: bytes | bytearray) -> int: | ||
| """Return how many trailing bytes start a UTF-8 sequence that is not finished yet.""" | ||
| # A sequence is at most four bytes, so the lead byte is within four of the end. | ||
| for back in range(1, min(4, len(data)) + 1): | ||
| byte = data[-back] | ||
| if byte < 0x80: | ||
| # ASCII cannot be part of a multi-byte sequence, so nothing is pending. | ||
| return 0 | ||
| if byte < 0xC0: | ||
| # Continuation byte. Keep walking back to find the lead byte it belongs to. | ||
| continue | ||
| # Only real lead bytes can still be completed. 0xC0, 0xC1 and 0xF5 to 0xFF never | ||
| # start a valid sequence, so carrying them would withhold a byte that no later | ||
| # output can finish and would suppress the replacement character forever. | ||
| if 0xC2 <= byte <= 0xDF: | ||
| needed = 2 | ||
| elif 0xE0 <= byte <= 0xEF: | ||
| needed = 3 | ||
| elif 0xF0 <= byte <= 0xF4: | ||
| needed = 4 | ||
| else: | ||
| return 0 | ||
|
Comment on lines
+31
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a collection window ends after one or more continuation bytes—for example, a three-byte character split as Useful? React with 👍 / 👎. |
||
| return back if back < needed else 0 | ||
| # Four trailing bytes with no lead byte cannot be completed either. | ||
| return 0 | ||
|
|
||
|
|
||
| async def collect_pty_output( | ||
| *, | ||
| output_chunks: deque[bytes], | ||
|
|
@@ -45,6 +72,18 @@ async def collect_pty_output( | |
| break | ||
| output_notify.clear() | ||
|
|
||
| if not is_done(): | ||
| # A multi-byte character can straddle two collection windows. Decoding a partial | ||
| # sequence with errors="replace" destroys those bytes, so hold the unfinished tail | ||
| # back for the next window. Once the producer is done nothing can complete it, so | ||
| # the replacement behaviour below is the right answer then. | ||
| carry = _incomplete_utf8_suffix_length(output) | ||
| if carry: | ||
| tail = bytes(output[-carry:]) | ||
| del output[-carry:] | ||
| async with output_lock: | ||
| output_chunks.appendleft(tail) | ||
|
Comment on lines
+84
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Unix child has exited but AGENTS.md reference: AGENTS.md:L149-L149 Useful? React with 👍 / 👎. |
||
|
|
||
| text = output.decode("utf-8", errors="replace") | ||
| truncated, original_token_count = truncate_text_by_tokens(text, max_output_tokens) | ||
| return truncated.encode("utf-8", errors="replace"), original_token_count | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The revised range checks reject bad lead bytes, but they still carry prefixes that can never become valid, such as
E0 80,ED A0,F0 80, orF4 90; UTF-8 restricts the second byte for these lead bytes. If an interactive process prints one of these malformed prefixes and then waits for input, every poll drains and requeues the same bytes without emitting the existing U+FFFD replacements. Validate the restricted second-byte ranges before classifying the suffix as incomplete.Useful? React with 👍 / 👎.