ENG-981: heal lone surrogates before compile() in the scratchpad - #268
Conversation
The scratchpad child crashes at `compile(code, "<scratchpad>", "exec")` when the
cell source carries a lone surrogate (\udcXX) — a non-ASCII Windows path byte
(e.g. "Área de Trabalho", an emoji filename) surrogate-escaped upstream and
passed through the belt's lenient surrogateescape stdin. compile() strict-encodes
the source and rejects the surrogate ("surrogates not allowed"), killing the cell
before any user code runs. Verified on 3 real desktop users (sabrina/eddie/tim).
This is a distinct site from ENG-824 (GBK decode) and ENG-940 (chat.py read
encoding); neither fixes it, and the ENG-824 belt (PYTHONUTF8) actually *enables*
it (the lenient stdin creates the surrogate; strict compile() then rejects it).
Fix: heal_surrogate_source() in wire.py, called in scratchpad_boot.py right
before compile(). It re-encodes via surrogateescape and decodes as UTF-8, which
*reassembles* the correct character when the surrogates are the byte-escaped
halves of a real multibyte char (the common path case — so the cell compiles AND
opens the right file), and falls back to a replacement-char scrub for a
genuinely-lone surrogate (compile succeeds instead of crashing). No-op on clean
source. Lives in wire.py so both the child and parent can share it.
Also corrects two ENG-940 (#263) tests that overclaimed: _encode_cell_payload's
surrogateescape transport only makes the pipe lossless — it does NOT fix the
compile() crash (that was input-dependent relocation). Relabeled to state they
pin transport fidelity only; the real fix is the heal tested here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-reviewTest status — one thing reviewers should know: the two spawn-the-child e2e tests in Adversarial pass — no functional findings:
Requesting review. |
…pe + replace) — hard-review finding (ENG-981) Hard review caught a correctness gap in the heal: the recover path used a strict decode with a full surrogatepass-scrub fallback, so a MIXED cell (a recoverable byte-escaped path + an unrelated lone byte) raised on the lone byte and fell to the full scrub — mojibako-ing the recoverable "Área" too. Use errors="replace" on the surrogateescape decode instead: recoverable multibyte chars are reassembled and stray bytes scrubbed to U+FFFD in the SAME pass, so a recoverable path survives next to an unrecoverable byte. The surrogatepass fallback now only guards the encode-raise (high/unpaired surrogates outside DC80..DCFF). Still total (never raises), still no residual surrogate. Adds a mixed-cell regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hard review — findingsF1 · Correctness (fixed, c4a022b). The recover path used a strict decode + full F2 · Coverage gap (flagging, not fixed). No test exercises the actual F3 · Behavior note (accepted). A genuinely-lone/high surrogate degrades to U+FFFD, so the cell runs with a scrubbed char in that spot — for a path that surfaces as a clear Verified good (no action): the function is total (never raises — critical for a boot script), leaves no residual surrogate (compile always succeeds), preserves real text, and composes with F4 · Design note (kept as-is). Heals proactively via a per-cell char-scan guard rather than reactively ( |
pnewsam
left a comment
There was a problem hiding this comment.
Code Review
Verdict: REQUEST_CHANGES
The normal recovery path and compile-site wiring look sound, and CI run 837 passed. However, the fallback for surrogates outside DC80–DCFF applies surrogatepass to the entire cell, destroying otherwise recoverable UTF-8 path bytes elsewhere in that cell. This is the same mixed-cell data-loss class the latest commit intends to fix.
Summary of findings
| Severity | File | Issue |
|---|---|---|
| Major | anton/core/backends/wire.py:43 |
Unsupported surrogate fallback corrupts recoverable surrogate-escaped characters elsewhere in the cell |
| Minor | tests/test_scratchpad_utf8.py:153 |
Comment incorrectly names surrogatepass instead of surrogateescape |
What's working well
The clean-source fast path is appropriately cheap, healing occurs immediately before compile(), and the focused recovery tests cover accented and emoji UTF-8 sequences. CI is green.
The author’s noted boot-integration coverage gap remains a non-blocking deployment risk.
| # High/unpaired surrogates outside surrogateescape's DC80..DCFF range — | ||
| # the escape codec can't map them at all; surrogatepass can, and the | ||
| # replace-decode then scrubs them. This branch can't raise. | ||
| return code.encode("utf-8", "surrogatepass").decode("utf-8", "replace") |
There was a problem hiding this comment.
Major: This fallback corrupts recoverable surrogate-escaped UTF-8 elsewhere in the same cell.
For example, p="C:/\udcc3\udc81rea"; q="\ud800" enters this branch because of \ud800. Applying surrogatepass to the entire source turns the recoverable \udcc3\udc81 into replacement characters too, producing C:/������rea. This is the same mixed-cell data-loss class the latest commit intends to prevent.
Please scrub unsupported surrogates before doing the surrogateescape round trip, so DC80–DCFF sequences remain recoverable, and add this mixed-range regression case.
| @@ -152,33 +153,81 @@ def test_chat_module_has_no_bare_read_text(): | |||
| # and kills the whole session (users sabrina/eddie/janis). surrogatepass keeps | |||
There was a problem hiding this comment.
Minor: This comment says surrogatepass keeps the parent encoding alive, but _encode_cell_payload() uses surrogateescape, and the newly added explanation below correctly says surrogatepass breaks the round trip. Please update the stale wording.
…sam review, ENG-981) Review caught a real mixed-cell data-loss path I missed: a high/unpaired surrogate (outside DC80..DCFF, e.g. \ud800) makes the surrogateescape *encode* raise, so the whole cell fell to the surrogatepass full-scrub — destroying the recoverable byte-escaped "Á" elsewhere in the same cell (C:/Área -> C:/������rea). This is the same data-loss class the previous commit fixed for the decode side. Fix: two steps. (1) Replace surrogates outside the escapable DC80..DCFF range with U+FFFD up front, so one unmappable surrogate can't drag recoverable ones into a lossy path. (2) surrogateescape-encode + replace-decode the rest — now it can never raise, so the try/except is gone entirely. Recoverable DC80..DCFF byte-escapes survive next to any unmappable surrogate. Adds test_heal_preserves_recoverable_char_beside_unmappable_surrogate. Also fixes the stale surrogatepass/framing in the ENG-940 transport-test header comment (minor, same review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@pnewsam thanks — both addressed in 9e74c60. Major — you're right, real data loss I missed. A high/unpaired surrogate (outside DC80..DCFF, e.g. Fix is your suggestion — scrub the unmappable surrogates up front, then run the Minor — fixed. Rewrote the stale ENG-940 transport-test header comment; it no longer misnames 16/16 green; function still total + no-residual on the full soup. Re-requesting review. |
pnewsam
left a comment
There was a problem hiding this comment.
Code Review
Verdict: APPROVE
Re-reviewed at 9e74c60. Both previous findings are fully addressed: unmappable surrogates are now scrubbed before the surrogateescape recovery pass, preserving recoverable DC80–DCFF sequences elsewhere in mixed cells, and the stale transport documentation has been corrected.
No issues found. Code looks good to merge.
What's working well
The new regression test covers the exact mixed-range failure case. The implementation is total across the surrogate range, preserves clean input, leaves no residual surrogates, and CI run 838 is green.
…G-824 + ENG-981) The main hotfix must carry both halves of the anton UTF-8 crash fix: - ENG-824 belt (PYTHONUTF8 + explicit encodings) — decode side. - ENG-981 heal — the encode/compile() side, a live prod crash (sabrina/eddie/ tim on desktop): a non-ASCII Windows path byte arrives surrogate-escaped over the belt's lenient stdin and crashes compile() with "surrogates not allowed". ENG-981 can't be a straight cherry-pick to main: its staging PR (#268) also edits the ENG-940 (#263) transport tests, and #263 is intentionally NOT on main (chat.py divergence). So the curated ENG-981 fix is folded here instead: - wire.py: heal_surrogate_source() (identical to the #268/staging version). - scratchpad_boot.py: heal the cell source right before compile(). - tests: the heal unit tests only (no _encode_cell_payload dependency). heal_surrogate_source: scrub surrogates outside DC80..DCFF, then surrogateescape- encode + replace-decode — reassembles byte-escaped multibyte paths exactly (correct path, not just non-crashing), scrubs the rest, never raises. Approved on #268 (pnewsam) and fuzzed clean over 200k mixed inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
The scratchpad child crashes at
compile(code, "<scratchpad>", "exec")(scratchpad_boot.py) when the cell source contains a lone surrogate (\udcXX). Heal the source before compile.Why (traced to source, 3 real desktop users)
A non-ASCII Windows path byte —
Área de Trabalho(sabrina), an emoji filename (eddie), and tim (Tech With Tim, dogfooding creator) — is surrogate-escaped upstream and passed through the belt's lenientsurrogateescapestdin, becoming a lone surrogate incode.compile()strict-encodes the source and rejects it →surrogates not allowed, killing the cell before any user code runs.Distinct from ENG-824 / ENG-940, and neither fixes it:
PYTHONUTF8) enables it — the lenient stdin creates the surrogate that strictcompile()rejects.encoding="utf-8"tochat.pyreads — different file/channel; strict UTF-8 wouldn't pass a lone surrogate anyway.How
heal_surrogate_source()inwire.py(shared by child + parent), called inscratchpad_boot.pyright beforecompile():surrogateescape→ decode UTF-8: reassembles the correct character when the surrogates are the byte-escaped halves of a real multibyte char (the common path case — cell compiles and opens the right file);This is the recover variant, chosen over the ticket's suggested
surrogatepass→replacescrub because the scrub corrupts recoverable paths into mojibake (verified) — recover makes the task actually work.Tests (14/14 green)
Ápath and an escaped emoji (assert exact reconstruction + compiles); scrub a truly-lone surrogate (no\udcXX, compiles); no-op on clean source; premise guard (barecompile()on a lone surrogate still raises)._encode_cell_payload's surrogateescape transport only makes the pipe lossless — it does not fix thecompile()crash. Relabeled to pin transport fidelity only.Security
No new surface — the change only normalizes the encoding of already-received cell source before compilation; no new input, no trust-boundary change.
Delivery
Targets
staging. The fix site inscratchpad_boot.pyis stable acrossmain/staging, so this cherry-picks cleanly to amainhotfix PR to follow (ENG-981 is Urgent/Customer; desktop users bleeding). Deploy caveat: reaching sabrina/eddie/tim needs a desktop app build from the release ref, not just the branch merge.Related: ENG-824 (belt), ENG-940 (read encoding), ENG-980 (churn tracker).