Conversation
Every host sends a domain message by interpolating it into a `Runtime.evaluate` JS source string, which Hermes compiles from UTF-8 and which `JSON.stringify` leaves non-ASCII. A payload with an astral-plane code unit therefore reached the device as a raw surrogate pair, Hermes refused to compile it, and the message vanished without a trace: editing a stored value containing an emoji in the Storage, MMKV, or SQLite panels looked like it worked. Escape every non-ASCII code unit of the finished expression as a `\uXXXX` sequence, which the device's own parser turns back into the original code unit, so the payload it reconstructs is byte-identical. Applied after the second `JSON.stringify`, the only position at which the escape text is not itself re-escaped; the three hosts that speak this protocol each keep their own copy, like the dispatcher-wait poll they already duplicate. Also stop swallowing the failure: the two hosts whose sends are fire-and-forget now report a device-refused evaluation, and the agent session rejects, so `bootstrap` retries the handshake and an agent tool call surfaces an error instead of an empty result.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
A message the app was sent containing an emoji — or any other character outside the Basic Multilingual Plane — never reached the app. Editing a stored value that contains one in the Storage, MMKV, or SQLite panels reported success and did nothing.
All three hosts that talk to a device (
RozeniteBindingsModel.sendMessagein the embedded DevTools shell, the agent session'ssendDomainMessage, and the standalone app's device connection) build the sameRuntime.evaluateexpression: serialize the payload,JSON.stringifyit again to turn it into a JS string literal, and interpolate that into source text for the device's dispatcher to parse.JSON.stringifyescapes quotes, backslashes and control characters — and leaves every non-ASCII code unit raw. Hermes compiles that source text from UTF-8 and refuses a raw astral-plane code unit withInvalid UTF-8 code point, so the evaluation never ran. No call site looked at the response, so a message the device never received was indistinguishable from one it did.The fix escapes every non-ASCII code unit of the finished expression as a
\uXXXXsequence, which makes the injected source text pure ASCII — the form Hermes always compiles. The device's own parser turns the escape back into the original code unit, so the payload it reconstructs is byte-identical to the one that was serialized.A message the device refuses is now reported instead of dropped: the two hosts whose sends are fire-and-forget log it, and the agent session rejects, so a failed
agent-session-readyhandshake is retried bybootstraprather than recorded as a ready session.Related Issue
Closes #407
Context
Why escaping rather than
Runtime.callFunctionOn. #408 switched the send path tocallFunctionOnwith a by-value argument and was closed because the transport change measured as no win — the secondJSON.stringifycosts ~70µs on a 52KB payload, on the host, never on the device's JS thread. The defect is in the payload text, not the transport, so the fix belongs there. The doubleJSON.stringifyalso stays: it is the wire contract, since the device's dispatcher is handed a JSON string and parses it. #408's two preserved review findings are encoded here instead of being re-litigated:invoke_*methods never reject, so the runtime host inspectsresponse.exceptionDetailsrather than attaching a.catch(), which would be dead code. Catching a protocol-level failure there would mean addinggetError()to the hand-writtenrn-devtools-frontend-api.d.ts; left out deliberately, and the other two hosts already reject on a protocol error in theirsendCommand.JSON.stringifyoutput sees\uXXXXescape text, not raw code units. That is why this escaping happens on the finished source text, after the secondJSON.stringify— and why it cannot happen before it: escaped earlier, the next stringify escapes the backslash too and the device is handed\uD83Cas six characters of text.Why one copy per host rather than a shared helper.
@rozenite/appdoes not depend on@rozenite/runtime, and the only package all three share,@rozenite/tools, publishes its browser-safe surface as the./integrationsubpath precisely because its index pulls innode:fsandnode:path. Sharing four lines would therefore mean a third subpath export plus a secondrollupTypesentry, for a helper small enough to read at the call site. These hosts already each own a private copy of this exact protocol shape —RUNTIME_GLOBAL, the double stringify, the dispatcher-wait poll, the binding handshake, withdevice-connection.tsdescribing itself as a port ofsession.ts— so the escaping joins them: one identical copy each, a comment naming the other two, and a guard in each host's own suite pinning the expression it emits. Drift between them fails a test. Promoting the three copies into a shared subpath later is a mechanical move, and cheaper done once there is a fourth host to justify it.Scope, and what is deliberately not here.
\uD83Descape text, which survives escaping untouched and is what the device's JSON parser rejects in the first place. Fixing that means atoWellFormed()replacer on the payload — a separate decision with a host-support requirement, and a rarer failure than the one reported here.Testing
Automated, from the repository root after
git fetch origin main:pnpm checks:affected— 98 tasks green (typecheck + lint across affected packages, plusoxfmt --check .over the repo).pnpm test:affected— 61 tasks green. It neededTURBO_CONCURRENCY=2: at default concurrency three pluginrelease-bundle.test.tsbenches, which drive a real Metro bundle, exceeded their 120s timeout on this machine. Each passes on its own in ~45–60s, none of them import anything this change touches, and they are green under limited concurrency.pnpm release:plan— version plan present for@rozenite/app,@rozenite/middleware,@rozenite/runtime.The guards assert two things about the expression a host emits, and the first is the important one: it matches
/^[\x20-\x7E]+$/. Node and V8 accept the raw astral form that Hermes rejects, so replaying the expression alone would not have reproduced this bug — a pure-ASCII expression is the property that guarantees Hermes' UTF-8 decode has nothing to fail on. The second half replays the device side anyway: evaluate the expression against a fake dispatcher,JSON.parsethe payload it is handed, and compare it to what went in, which is what keeps the escaping from quietly mangling data. The middleware suite additionally asserts that a refusedagent-session-readyis retried and thatstart()does not resolve over it.What was not run here: the Playground scenario below, and any device run at all. The Hermes behavior is reproduced from the issue's own raw-CDP script and from #408's device check of this same escape-based fix, not re-measured.
Manual scenario for a reviewer, with the Playground app and Metro running:
🎉, and save.mainthe write silently does not happen — the panel shows the old value again, and nothing is logged anywhere. On this branch the write lands, and the value reads back with the emoji intact.