Tolerate lone UTF-16 surrogates in renderer JSON (#4710)#4745
Tolerate lone UTF-16 surrogates in renderer JSON (#4710)#4745AbanoubGhadban wants to merge 4 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
💤 Files with no reviewable changes (1)
WalkthroughAdds ChangesSurrogate-tolerant server rendering
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant JavaScriptRenderer
participant RubyEmbeddedJavaScript
participant LengthPrefixedParser
participant LenientJson
JavaScriptRenderer->>RubyEmbeddedJavaScript: emit legacy render result JSON
RubyEmbeddedJavaScript->>LenientJson: parse render result
JavaScriptRenderer->>LengthPrefixedParser: emit length-prefixed chunks
LengthPrefixedParser->>LenientJson: parse metadata or object payload
LenientJson-->>RubyEmbeddedJavaScript: return repaired parsed result
LenientJson-->>LengthPrefixedParser: return repaired parsed payload
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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 |
Greptile SummaryThis PR makes renderer JSON parsing tolerant of lone UTF-16 surrogate escapes. The main changes are:
Confidence Score: 4/5Low-surrogate renderer output and stale Pro lockfiles need fixes before merging.
react_on_rails/lib/react_on_rails/lenient_json.rb and the Pro dummy lockfiles Important Files Changed
Reviews (1): Last reviewed commit: "Tolerate lone UTF-16 surrogates in rende..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Around line 40-48: Update the changelog entry for Issue 4710 to append the
repository-standard PR link and author attribution in the required “[PR …] by
[author]” format, preserving the existing description and issue link.
In `@react_on_rails/lib/react_on_rails/lenient_json.rb`:
- Around line 30-38: Update the successful-parse path in LenientJSON to inspect
inputs containing "\u" escapes for standalone low surrogates, repair them to
U+FFFD, and reparse only when the repaired input differs from the original.
Preserve existing handling for valid pairs and lone high surrogates, and add a
regression test covering a standalone low-surrogate escape.
- Around line 69-75: Update the rescue flow around repair_lone_surrogates so
JSON.parse(repaired) errors are rescued and the original JSON::ParserError is
re-raised, preserving its message and backtrace when repaired JSON remains
malformed. Extend the malformed-surrogate spec to assert the original error
message and backtrace in addition to its class.
In `@react_on_rails/spec/lib/react_on_rails/lenient_json_spec.rb`:
- Around line 3-6: Move the LenientJson unit suite from
react_on_rails/spec/lib/react_on_rails/lenient_json_spec.rb to
react_on_rails/spec/react_on_rails/, preserving its existing coverage and
requires. Relocate the LengthPrefixedParser unit coverage at
react_on_rails/spec/lib/react_on_rails/length_prefixed_parser_spec.rb:132-151 to
a suite under react_on_rails/spec/react_on_rails/, moving the existing suite if
appropriate; both sites require relocation only, with no behavioral changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7e1592bb-bf0a-45df-817f-718454f4b214
⛔ Files ignored due to path filters (2)
react_on_rails/Gemfile.lockis excluded by!**/*.lockreact_on_rails/spec/dummy/Gemfile.lockis excluded by!**/*.lock
📒 Files selected for processing (7)
CHANGELOG.mdreact_on_rails/lib/react_on_rails/length_prefixed_parser.rbreact_on_rails/lib/react_on_rails/lenient_json.rbreact_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rbreact_on_rails/react_on_rails.gemspecreact_on_rails/spec/lib/react_on_rails/length_prefixed_parser_spec.rbreact_on_rails/spec/lib/react_on_rails/lenient_json_spec.rb
The JS renderer can emit strings containing a lone UTF-16 surrogate -- most commonly from truncating text mid-emoji (e.g. an excerpt cut with slice/substring, which leaves the high half of a surrogate pair). JSON serializes it as a \uXXXX escape that Ruby's JSON.parse can reject with "incomplete surrogate pair", failing the entire server render over one bad character. Add ReactOnRails::LenientJson, which parses normally and only on failure repairs lone-surrogate escapes to the Unicode replacement character (U+FFFD) and retries. Clean payloads pay nothing -- the rescue costs nothing until an exception is raised, and the repair scan/allocation happen only on the failure path. Valid surrogate pairs and literal backslash text are preserved. Wire it into the three JSON.parse sites that consume renderer output: metadata and object payloads in LengthPrefixedParser, and the legacy JSON path in ruby_embedded_java_script. The repair engages wherever JSON.parse raises. Two cases it does not cover, both documented in LenientJson: a lone *low* surrogate never raises on any json version, and on json < 2.17 a lone *high* surrogate may be silently degraded by the C parser instead of raising -- but neither case crashes, so nothing regresses on older json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
d92d685 to
aee33d4
Compare
|
Thanks for the review. Triage of the four comments:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aee33d4c41
ℹ️ 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".
Review SummaryWhat this PR does: Adds Strengths
Main issue: the stated
|
…review) Address review feedback: rescuing JSON::ParserError alone missed three real cases where JSON.parse succeeds but returns wrong/invalid text: - a lone LOW surrogate is accepted and returns invalid UTF-8 (greptile, CodeRabbit) - a lone HIGH surrogate before a non-low escape (e.g. an escaped control char) is silently mis-decoded into a bogus astral character (Codex) - on json < 2.17, a lone HIGH surrogate is silently degraded to "?" Repair now runs on the success path too, gated by a single cheap backslash-u-d substring check so clean payloads pay only ~170ns (metadata) and nothing scans the large HTML string-payload, which is never JSON-parsed. Results are now consistent across json 2.7.2 and 2.21: every lone surrogate becomes U+FFFD, valid pairs kept. Also document the valid-UTF-8 input assumption (input is JSON.stringify output, which is well-formed UTF-16 and always encodes to valid UTF-8), and add regression tests for the lone-low, escaped-control, and no-surrogate fast-path cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review addressed (commit 2baaa8e)Fixed (real issues):
Stale (resolved by the earlier json-floor revert):
Kept with rationale (replied inline):
Tests pass on json 2.7.2 and 2.21.1; RuboCop clean. |
Code ReviewOverviewThis PR fixes server-render crashes caused by lone UTF-16 surrogates in JS-renderer JSON output (#4710). It adds Strengths
Issues found (left as inline comments)
Minor / non-blocking
Overall this is a well-designed, well-tested fix for a real crash. The main ask is reconciling the CHANGELOG/PR description with what's actually implemented, plus a look at the |
…d 2) Address claude[bot] review of the success-path change: - repair_lone_surrogates now scans a binary copy and restores the original encoding, so a genuinely invalid-UTF-8 payload can no longer turn the repair into an ArgumentError that a caller's `rescue JSON::ParserError` would miss (the Pro streaming caller has no broad rescue). Detection already used a byte-safe `include?`. Invalid bytes pass through and resurface as an ordinary JSON::ParserError. Out of scope for JSON.stringify output (always valid UTF-8), but cheap and strictly safer; added a regression test. - Correct the CHANGELOG entry: it described the earlier "repair only runs after a parse error" / ">= 2.17 floor" design. The shipped code repairs on the success path too and works the same across json versions; the entry now says so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review: Tolerate lone UTF-16 surrogates in renderer JSON (#4710)Overall this is a well-designed, carefully-reasoned fix — I traced the 🔴 Confirmed bug: literal NUL byte embedded in
|
…4710) The binary-copy scan guarded an ArgumentError that cannot occur: input to these parse sites is always JSON.stringify output, which is well-formed UTF-16 and thus always valid UTF-8, so the repair regex never sees invalid bytes. The defensive conversion added complexity for an unreachable case, so revert to the plain gsub and keep the documented input assumption. All real fixes from this round (lone-low, escaped-control mis-decode, CHANGELOG correction) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
This example contains a literal NUL byte rather than the printable sequence \u0000, causing Git to classify the entire new Ruby file as binary in normal diffs. Future changes to this parser will therefore be hidden from ordinary textual diffs, inline review, and text-oriented tooling; spell the example as \u0000 instead.
ℹ️ 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".
Review SummaryThis PR adds ReactOnRails::LenientJson, a wrapper around JSON.parse that repairs lone UTF-16 surrogate escapes in renderer output, replacing them with U+FFFD instead of crashing the render. It is wired into the three JSON.parse call sites that consume Node-renderer output (length_prefixed_parser.rb metadata + object payload, and the legacy ruby_embedded_java_script.rb path). Good test coverage in lenient_json_spec.rb and length_prefixed_parser_spec.rb. Bug: stray raw NUL byte in the new filereact_on_rails/lib/react_on_rails/lenient_json.rb contains a literal NUL (0x00) byte on line 25, inside the doc comment describing the "silently mis-decoded" case, right before the closing quote of the example escape text shown there (it appears to have replaced what was meant to be a space character). I confirmed this at the byte level with The practical impact: It sits inside a comment, so Ruby itself parses the file fine and the specs pass -- but this is clearly an unintended artifact and should be removed before merge. Leaving it in permanently marks this file as binary in git history and tooling, and hides its diffs from reviewers on every future PR that touches it. Description vs. implementation mismatchThe PR description's "Why the json floor moves to >= 2.17" and "Known limitation" sections do not quite match the shipped code:
Minor / non-blocking
What looks solid
|
Review loop converged (commit 008e931)All review threads addressed and resolved. Latest re-review raised no new comments. Round-2 outcome:
Real fixes retained from this PR: lone-high (raise), lone-low (silently invalid UTF-8), lone-high-before-non-low-escape (silently mis-decoded), and older- All checks green except |
Problem
When the JavaScript renderer produces a string containing a lone UTF-16 surrogate, server rendering crashes. This happens most commonly from truncating text mid-emoji — e.g. an excerpt/teaser cut with
slice/substringthat lands inside a surrogate pair, leaving the high half orphaned. JavaScript holds this happily, andJSON.stringifyserializes it as the escape\ud83d, but Ruby'sJSON.parserejects that escape:One bad character takes down the entire render. Fixes #4710.
Fix
New
ReactOnRails::LenientJsonparses normally and only on failure repairs lone-surrogate escapes to the Unicode replacement character (�, U+FFFD) and retries:rescuecosts nothing until an exception is raised, and the repair scan/allocation happen only on the failure path.\\ud83dtext are preserved (the repair is pair-aware and backslash-parity-aware).�matches what a browser renders for broken text anyway — passing content through instead of failing, which is the behavior the issue asks for.Wired into the three
JSON.parsesites that consume renderer output: metadata and object payloads inLengthPrefixedParser, and the legacy path inruby_embedded_java_script.Why the json floor moves to
>= 2.17json
< 2.17(including 2.7.2, previously bundled) silently mangles a lone high surrogate inside the C parser without raising — so the rescue could never fire. json>= 2.17raisesincomplete surrogate pair, which is exactly what makes the repair reachable. Confirmed empirically across json 2.7.2 / 2.17.1 / 2.20.0.Known limitation (documented in the code)
A lone low surrogate does not make
JSON.parseraise on any json version — Ruby accepts it and returns invalid UTF-8 — so the rescue does not fire for it. That case comes from the rarer prefix-drop path; the common source (end-truncation) yields a lone high surrogate, which raises and is handled. Catching the low case would require inspecting every successful parse result, a per-request cost this deliberately avoids.Testing
lenient_json_spec.rb: clean JSON unchanged, valid astral char untouched, lone high / lone-in-key / reversed-pair repaired, valid escape-pair preserved, literal backslash text untouched, malformed JSON re-raises.length_prefixed_parser_spec.rb: lone surrogate in metadata and in an object payload no longer fail the render.🤖 Generated with Claude Code
Summary by CodeRabbit