Skip to content

fix(sandbox): keep split UTF-8 characters intact across PTY output windows - #4724

Open
ayaangazali wants to merge 4 commits into
openai:mainfrom
ayaangazali:fix/pty-utf8-window-boundary
Open

fix(sandbox): keep split UTF-8 characters intact across PTY output windows#4724
ayaangazali wants to merge 4 commits into
openai:mainfrom
ayaangazali:fix/pty-utf8-window-boundary

Conversation

@ayaangazali

Copy link
Copy Markdown
Contributor

Summary

collect_pty_output decodes each collection window with errors="replace":

text = output.decode("utf-8", errors="replace")

PTY output is not collected once. It is collected in repeated windows over one persistent chunk deque: pty_exec_start takes a window, then each pty_write_stdin takes another from the same entry.output_chunks. So a multi-byte character whose bytes land either side of a window boundary is decoded as two separate partial sequences, and both halves become U+FFFD. The bytes are destroyed at that first decode, so no later window can put the character back together.

Reproduced against main with one persistent deque, the producer still running for the first window:

window 1 out = b'h\xef\xbf\xbd'
window 2 out = b'\xef\xbf\xbdllo w\xc3\xb6rld \xe2\x9c\x85'
joined       : h??llo wörld ✅     <- two replacement characters

After the change the same sequence round trips, with the first window ending on b"h" and holding the partial byte back:

window 1 out = b'h'
window 2 out = b'\xc3\xa9llo w\xc3\xb6rld \xe2\x9c\x85'
joined       : héllo wörld ✅

The fix holds an unfinished trailing sequence back on the deque for the next window. It needs no new state, because the deque already outlives a single window and is owned by the PTY entry. When is_done() reports the producer has closed, nothing can complete the sequence, so the existing replacement behaviour is kept for that case.

This is not backend-specific. collect_pty_output is the shared helper behind the unix_local, docker, blaxel, daytona and modal PTY paths, so any of them silently mangles a character that straddles a window today. It is the same class as #4707, which fixed a UTF-8 boundary in the Cloudflare SSE reader; this is the boundary that the shared PTY collector has.

Note on overlap: #4572 also edits this function, adding a final drain immediately above the decode line. The two changes are independent and compose, but they will touch adjacent lines, so this may need a trivial rebase depending on merge order.

Test plan

Two tests in tests/sandbox/test_pty_output.py:

  • test_collect_pty_output_holds_split_utf8_character_for_the_next_window splits a two-byte character across two windows on one deque, asserts the first window ends on b"h" rather than emitting a replacement character, and asserts the two windows concatenate back to the original text.
  • test_collect_pty_output_replaces_partial_utf8_once_the_producer_is_done pins the deliberate exception: after is_done() is true a truncated sequence still becomes U+FFFD and is not left stranded on the deque.

Verified the first fails without the source change by reverting src/: it fails on the b"h" assertion, which is the corruption itself. The second passes either way, since it pins existing behaviour.

.agents/skills/code-change-verification/scripts/run.sh passes end to end: format, lint, typecheck and the full suite.

Issue number

None. Found while sibling-checking #4707.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

I went looking for this straight after #4707 landed, on the theory that a UTF-8 boundary bug in one reader usually has a sibling wherever else the SDK decodes bytes it did not accumulate itself. The judgement call I would most like checked is keeping errors="replace" once the producer is done, since holding bytes back forever would be worse than losing them. I'm a freshman in college and the PTY collector is a path I only learned recently, so please push back if the carry-back belongs in the backends rather than the shared helper.

…ndows

collect_pty_output decodes each window with errors="replace". PTY output is
collected in repeated windows over one persistent chunk deque, so a multi-byte
character whose bytes land either side of a window boundary is decoded as two
partial sequences and both halves become U+FFFD. The original bytes are
destroyed at that first decode, so no later window can recover them.

Hold an unfinished trailing sequence back on the deque for the next window
instead. Once is_done() reports the producer has closed, nothing can complete
the sequence, so the existing replacement behaviour still applies there.

The helper is shared by the unix_local, docker, blaxel, daytona and modal
backends, so all of them lose the character today.
Copilot AI lite review requested due to automatic review settings August 27, 2026 23:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9e76be54c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

byte = data[-back]
if byte < 0x80:
return 0
if byte >= 0xC0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject invalid UTF-8 lead bytes before carrying them

When a running PTY's current output ends with an invalid UTF-8 byte such as 0xC0, 0xC1, or 0xF50xFF, this condition misclassifies it as the start of an incomplete character. The collector therefore requeues the byte and returns no replacement character; repeated polls keep requeuing it indefinitely until the process emits more output or exits. This regresses the existing errors="replace" handling for arbitrary PTY bytes, particularly for an interactive process that prints a non-UTF-8 prompt and then waits for input. Only valid lead-byte ranges (C2DF, E0EF, and F0F4) should be eligible for carry-over.

Useful? React with 👍 / 👎.

The carry-over check treated any byte at or above 0xC0 as a lead byte, but
0xC0, 0xC1 and 0xF5 to 0xFF never start a valid sequence. A running PTY that
emitted one of those as its last byte had it requeued every poll and never
replaced, so an interactive process printing a non-UTF-8 prompt and then
waiting for input withheld that byte indefinitely.

Restrict carry-over to the real lead ranges C2 to DF, E0 to EF and F0 to F4.
Everything else falls through to the existing replacement behaviour.
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Good catch, this was a real regression in my change and it is fixed in eb683f31.

You are right that 0xC0, 0xC1 and 0xF5 to 0xFF never start a valid sequence, so treating anything at or above 0xC0 as a lead byte meant those bytes were requeued forever instead of being replaced. Reproduced it across repeated polls on a still-running producer:

before   invalid lead 0xC0   outs=[b'prompt', b'', b'']            still_queued=[b'\xc0']
         invalid lead 0xFF   outs=[b'prompt', b'', b'']            still_queued=[b'\xff']
after    invalid lead 0xC0   outs=[b'prompt\xef\xbf\xbd', b'', b''] still_queued=[]
         invalid lead 0xFF   outs=[b'prompt\xef\xbf\xbd', b'', b''] still_queued=[]

Your interactive-prompt example is the case that made this matter: the byte was withheld while the process sat waiting for input, so the prompt never reached the caller at all. That is worse than the replacement character it was meant to avoid.

Carry-over is now restricted to C2 to DF, E0 to EF and F0 to F4, and everything else falls through to the existing errors="replace" path. A parametrized test over 0xC0, 0xC1, 0xF5 and 0xFF asserts the byte is replaced in the same window and nothing is left queued; it fails on the previous commit for all four. The valid-but-incomplete case still carries as before.

Full verification stack passes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb683f3199

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +26 to +27
else:
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Carry suffixes that include continuation bytes

When a collection window ends after one or more continuation bytes—for example, a three-byte character split as E2 82 | AC—the scan examines 0x82 first and immediately returns zero because it is not a lead byte. The first window therefore decodes E2 82 as U+FFFD and the next decodes AC separately, so this fix still corrupts three- and four-byte characters at most possible split points. The scan needs to traverse trailing continuation bytes back to their valid lead byte before deciding how much to carry.

Useful? React with 👍 / 👎.

… byte

The previous commit returned zero as soon as the scan saw a byte that was not
a lead byte, but continuation bytes are exactly what a split three or four byte
character leaves at the end of a window. A character split as E2 82 | AC was
therefore still decoded as two replacement characters, so only two byte
characters were actually protected.

Skip continuation bytes and keep walking back to the lead byte, scanning up to
four bytes since that is the longest sequence. Four trailing bytes with no lead
byte cannot be completed, so they fall through to replacement as before.

The helper is now covered directly across every split position of every
sequence length, which is what the earlier window level test missed.
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Right again, and this one was worse than it looked: my previous commit only ever protected two-byte characters.

The scan returned zero the moment it saw a byte that was not a lead byte, and a continuation byte is exactly what a split three or four byte character leaves at the end of a window. So E2 82 | AC hit 0x82 first, returned zero, and both halves still became replacement characters.

Fixed in 65a333b3 by skipping continuation bytes and walking back to the lead byte, scanning up to four bytes since that is the longest sequence. Four trailing bytes with no lead byte still fall through to replacement.

The real lesson is that my test only covered a two-byte character split after one byte, which is the single case that happened to work. The helper is now tested directly across every split position of every sequence length:

ascii 0 | 2-byte: split-1 1, complete 0
3-byte: split-1 1, split-2 2, complete 0
4-byte: split-1 1, split-2 2, split-3 3, complete 0
invalid leads C0 C1 F5 FF 0 | stray continuation 0 | four continuations 0

Plus a window-level test parametrized over both split points of a three-byte character. On the previous commit the split-2 and split-3 rows and the three-byte window case all fail, which is the gap you described.

Full verification stack passes. One unrelated flake, test_multiple_tool_calls_bound_cancelled_sibling_self_rescheduling_cleanup, appeared once under xdist and passes in isolation and on a clean rerun; it is a cancellation test my diff does not touch.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65a333b3df

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +27 to +30
elif 0xE0 <= byte <= 0xEF:
needed = 3
elif 0xF0 <= byte <= 0xF4:
needed = 4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject UTF-8 prefixes whose second byte is already invalid

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, or F4 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 👍 / 👎.

Comment on lines +84 to +85
async with output_lock:
output_chunks.appendleft(tail)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve carried bytes when the process has already exited

When a Unix child has exited but _watch_process_exit is still awaiting its pump tasks before setting output_closed (unix_local.py:503-508), this code treats a truncated final sequence as resumable and moves it back into the deque. _finalize_pty_update then removes the entry as soon as process.returncode is set (unix_local.py:554-563), so no next window can drain the tail and the final byte is silently lost instead of producing U+FFFD. Coordinate the carry with completion and entry ownership at this mutation boundary, with controlled exit/pump ordering coverage.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

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.

3 participants