From c12d6fd5bef32b499e39fcb11348f941b57f05f7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 15 Sep 2026 11:36:20 +0200 Subject: [PATCH] fix: stop dropping host-to-device messages containing emoji 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. --- .changeset/host-to-device-message-encoding.md | 11 +++ .../src/connection/device-connection.test.ts | 64 +++++++++++++ .../app/src/connection/device-connection.ts | 44 ++++++++- .../src/__tests__/agent-session.test.ts | 88 ++++++++++++++++++ packages/middleware/src/agent/session.ts | 44 ++++++++- .../__tests__/bindings-model.test.ts | 93 ++++++++++++++++++- .../runtime/src/rn-devtools/bindings-model.ts | 42 ++++++++- 7 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 .changeset/host-to-device-message-encoding.md diff --git a/.changeset/host-to-device-message-encoding.md b/.changeset/host-to-device-message-encoding.md new file mode 100644 index 00000000..eeb5c318 --- /dev/null +++ b/.changeset/host-to-device-message-encoding.md @@ -0,0 +1,11 @@ +--- +'@rozenite/app': patch +'@rozenite/middleware': patch +'@rozenite/runtime': patch +--- + +Fix host-to-device messages carrying emoji or any other character outside the +Basic Multilingual Plane being silently dropped — editing a stored value that +contains one from the Storage, MMKV, or SQLite panels now reaches the app +instead of looking like it did. A message the device refuses to accept is +reported now, rather than lost without a trace. diff --git a/packages/app/src/connection/device-connection.test.ts b/packages/app/src/connection/device-connection.test.ts index 74497564..f7adffa2 100644 --- a/packages/app/src/connection/device-connection.test.ts +++ b/packages/app/src/connection/device-connection.test.ts @@ -139,6 +139,23 @@ const getExpressions = (socket: FakeWebSocket): string[] => .filter((command) => command.method === 'Runtime.evaluate') .map((command) => String(command.params?.expression ?? '')); +/** + * Replays what the device does with the injected source text: compile it, + * call the dispatcher, and `JSON.parse` the payload it is handed. + */ +const evaluateOnDevice = (expression: string): Array<[string, unknown]> => { + const delivered: Array<[string, unknown]> = []; + const dispatcher = { + sendMessage: (domain: string, payload: string): void => { + delivered.push([domain, JSON.parse(payload)]); + }, + }; + + new Function(RUNTIME_GLOBAL, expression)(dispatcher); + + return delivered; +}; + /** * The device-local page id a real target would report: the `page` query * parameter of its own `webSocketDebuggerUrl`, exactly like the @@ -411,6 +428,53 @@ describe('createDeviceConnection', () => { }); }); + describe('message encoding', () => { + it('keeps the injected expression ASCII-only so Hermes can compile it', async () => { + const { connection, socket } = await connectAndBootstrap(); + const message = { key: 'note', value: 'ship \u{1F389} it \u{2014} \u{65E5}\u{672C}\u{8A9E}' }; + + const before = socket.sent.length; + connection.send(message); + await waitUntil(() => socket.sent.length > before); + + const expressions = getExpressions(socket).filter((expression) => + expression.includes(`${RUNTIME_GLOBAL}.sendMessage("rozenite"`), + ); + expect(expressions).toHaveLength(1); + + // The bug this guards: Hermes compiles the expression as UTF-8 source + // and rejects a raw astral-plane code unit with `Invalid UTF-8 code + // point`, losing the message. The browser accepts that raw form, so this + // ASCII-only assertion is the part that actually reproduces the failure; + // the round trip below proves the escaping does not alter the payload. + expect(expressions[0]).toMatch(/^[\x20-\x7E]+$/); + expect(evaluateOnDevice(expressions[0] ?? '')).toEqual([['rozenite', message]]); + }); + + it('reports a message the device refused to evaluate', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { connection, socket } = await connectAndBootstrap(); + + socket.responder = (method, params) => { + const expression = String(params?.expression ?? ''); + if (method === 'Runtime.evaluate' && expression.includes('.sendMessage(')) { + return { exceptionDetails: { text: 'SyntaxError: Invalid UTF-8 code point' } }; + } + return defaultResponder(method, params); + }; + + const before = socket.sent.length; + connection.send({ key: 'note', value: 'hello' }); + await waitUntil(() => socket.sent.length > before); + await vi.advanceTimersByTimeAsync(50); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls[0][0]).toContain('Invalid UTF-8 code point'); + + consoleError.mockRestore(); + }); + }); + describe('message routing', () => { it('forwards rozenite-domain binding payloads and drops other domains', async () => { const { connection, socket } = await connectAndBootstrap(); diff --git a/packages/app/src/connection/device-connection.ts b/packages/app/src/connection/device-connection.ts index 600ab40d..9364d4a1 100644 --- a/packages/app/src/connection/device-connection.ts +++ b/packages/app/src/connection/device-connection.ts @@ -107,6 +107,30 @@ type CDPEvaluateResult = { const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Escapes every non-ASCII code unit as a `\uXXXX` escape sequence, so that the + * JS source text handed to `Runtime.evaluate` is pure ASCII. + * + * Hermes compiles that source text from UTF-8 and fails on a raw astral-plane + * code unit with `Invalid UTF-8 code point`, which loses the message without + * any visible error. The escape sequence it produces is plain ASCII, and the + * device's own parser turns it back into the original code unit, so the payload + * the device reconstructs is byte-identical to the one the host serialized. + * + * This has to run on the output of the second `JSON.stringify`, never before + * it: escaped earlier, `JSON.stringify` escapes the backslash instead and the + * device receives `\uD83C` as six characters of text. + * + * Identical in all three hosts that speak this protocol (here, the embedded + * bindings model in `@rozenite/runtime`, and the agent session in + * `@rozenite/middleware`), like the dispatcher-wait poll below. + */ +const toAsciiJsSource = (source: string): string => + source.replace( + /[^\0-\x7F]/g, + (codeUnit) => `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + /** A detached `void sendCommand(...)` (queue flushing, fire-and-forget * sends) has nothing else attached to observe its rejection. Ported from * `session.ts`'s `markPromiseAsHandled` so a socket dying mid-send surfaces @@ -292,11 +316,27 @@ export const createDeviceConnection = (target: ParsedTarget): DeviceConnection = const sendDomainMessage = (socket: WebSocket, message: unknown): Promise => { const serializedMessage = JSON.stringify(message); - const escapedMessage = JSON.stringify(serializedMessage); + const escapedMessage = toAsciiJsSource(JSON.stringify(serializedMessage)); + return markPromiseAsHandled( sendCommand(socket, 'Runtime.evaluate', { expression: `${RUNTIME_GLOBAL}.sendMessage(${JSON.stringify('rozenite')}, ${escapedMessage})`, - }).then(() => undefined), + }) + .then((response) => { + // The device rejecting the source text is reported on the response, + // so this is the only place a lost message can be noticed. Reported + // rather than thrown because every caller here is a detached + // fire-and-forget send (`send`, `flushQueue`) with nothing left to + // hand an error to. + const { exceptionDetails } = response as CDPEvaluateResult; + if (exceptionDetails) { + console.error( + '[rozenite] Failed to send a message to the rozenite domain: ' + + (exceptionDetails.text ?? 'unknown error'), + ); + } + }) + .then(() => undefined), ); }; diff --git a/packages/middleware/src/__tests__/agent-session.test.ts b/packages/middleware/src/__tests__/agent-session.test.ts index 582835f3..cda3a0de 100644 --- a/packages/middleware/src/__tests__/agent-session.test.ts +++ b/packages/middleware/src/__tests__/agent-session.test.ts @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => { const resolveDebuggerOrigin = vi.fn(); let bindingName = 'rozenite-binding'; let stalledRuntimeExpression: string | null = null; + let refusedRuntimeExpression: string | null = null; class MockWebSocket { static readonly CONNECTING = 0; @@ -151,6 +152,17 @@ const mocks = vi.hoisted(() => { }; } + // Models the device refusing to compile the injected source text (the + // `Invalid UTF-8 code point` failure of issue #407): the command itself + // succeeds, and the rejection is reported on the response. + if (refusedRuntimeExpression && expression.includes(refusedRuntimeExpression)) { + return { + exceptionDetails: { + text: 'Compiling JS failed: Invalid UTF-8 code point', + }, + }; + } + return {}; }; @@ -176,6 +188,9 @@ const mocks = vi.hoisted(() => { stallRuntimeEvaluationContaining: (expression: string) => { stalledRuntimeExpression = expression; }, + refuseRuntimeEvaluationContaining: (expression: string) => { + refusedRuntimeExpression = expression; + }, reset: () => { commandLog.length = 0; loggerInfo.mockReset(); @@ -197,6 +212,7 @@ const mocks = vi.hoisted(() => { }); bindingName = 'rozenite-binding'; stalledRuntimeExpression = null; + refusedRuntimeExpression = null; wsInstances.length = 0; resolveDebuggerOrigin.mockReset(); resolveDebuggerOrigin.mockImplementation( @@ -327,6 +343,26 @@ const getExpressions = (): string[] => { .map((command) => String(command.params?.expression ?? '')); }; +/** + * Replays what the device does with the injected source text: compile it, + * call the dispatcher, and `JSON.parse` the payload it is handed. + */ +const evaluateOnDevice = (expression: string): Array<[string, unknown]> => { + const delivered: Array<[string, unknown]> = []; + const dispatcher = { + sendMessage: (domain: string, payload: string): void => { + delivered.push([domain, JSON.parse(payload)]); + }, + }; + + new Function(RUNTIME_GLOBAL, expression)(dispatcher); + + return delivered; +}; + +const sendMessageExpressions = (expressions: string[]): string[] => + expressions.filter((expression) => expression.includes('sendMessage("rozenite"')); + const emitRozeniteBindingPayload = async ( socket: InstanceType, message: Record, @@ -535,6 +571,58 @@ describe('agent session', () => { expect(readyExpressions).toHaveLength(2); }); + it('keeps the injected expression ASCII-only so Hermes can compile it', async () => { + await startSession(); + + const sender = mocks.handler.connectDevice.mock.calls[0]?.[2] as + | { sendMessage: (message: unknown) => void } + | undefined; + expect(sender).toBeDefined(); + + const message = { + pluginId: '@rozenite/storage-plugin', + type: 'set-entry', + payload: { value: 'ship \u{1F389} it' }, + }; + sender?.sendMessage(message); + await flushMicrotasks(); + + const expressions = sendMessageExpressions(getExpressions()).filter((expression) => + expression.includes('set-entry'), + ); + expect(expressions).toHaveLength(1); + + // The bug this guards: Hermes compiles the expression as UTF-8 source and + // rejects a raw astral-plane code unit with `Invalid UTF-8 code point`, + // losing the message. Node accepts that raw form, so this ASCII-only + // assertion is the part that actually reproduces the failure; the round + // trip below proves the escaping the fix adds does not alter the payload. + expect(expressions[0]).toMatch(/^[\x20-\x7E]+$/); + expect(evaluateOnDevice(expressions[0])).toEqual([['rozenite', message]]); + }); + + it('retries the handshake when the device refuses the message', async () => { + // The device refuses the evaluation while the command itself succeeds: + // precisely the shape a lost message used to have. + mocks.refuseRuntimeEvaluationContaining('agent-session-ready'); + + const { socket, startPromise } = await createStartedSession(); + const onResolved = vi.fn(); + startPromise.then(onResolved); + + socket.open(); + await vi.advanceTimersByTimeAsync(500); + await flushMicrotasks(); + + expect(sendMessageExpressions(getExpressions())).toHaveLength(1); + expect(onResolved).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(500); + await flushMicrotasks(); + + expect(sendMessageExpressions(getExpressions())).toHaveLength(2); + }); + it('heals a relaunched app with the same device id and a new page id', async () => { const replacementTarget = createTarget({ pageId: 'page-2', diff --git a/packages/middleware/src/agent/session.ts b/packages/middleware/src/agent/session.ts index 536a458c..36ac8ed8 100644 --- a/packages/middleware/src/agent/session.ts +++ b/packages/middleware/src/agent/session.ts @@ -53,6 +53,30 @@ const DEVTOOLS_TOOK_CONNECTION_REASON = '[NEW_DEBUGGER_OPENED]'; const getCloseReason = (reason: unknown): string => Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? ''); +/** + * Escapes every non-ASCII code unit as a `\uXXXX` escape sequence, so that the + * JS source text handed to `Runtime.evaluate` is pure ASCII. + * + * Hermes compiles that source text from UTF-8 and fails on a raw astral-plane + * code unit with `Invalid UTF-8 code point`, which loses the message without + * any visible error. The escape sequence it produces is plain ASCII, and the + * device's own parser turns it back into the original code unit, so the payload + * the device reconstructs is byte-identical to the one the host serialized. + * + * This has to run on the output of the second `JSON.stringify`, never before + * it: escaped earlier, `JSON.stringify` escapes the backslash instead and the + * device receives `\uD83C` as six characters of text. + * + * Identical in all three hosts that speak this protocol (here, the embedded + * bindings model in `@rozenite/runtime`, and the device connection in + * `@rozenite/app`), like the dispatcher-wait poll further down. + */ +const toAsciiJsSource = (source: string): string => + source.replace( + /[^\0-\x7F]/g, + (codeUnit) => `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + type PendingCommand = { /** Retained so a device error can name the method it refused. */ method: string; @@ -376,11 +400,27 @@ export const createAgentSession = (options: { const sendDomainMessage = (domain: string, message: unknown): Promise => { const serializedMessage = JSON.stringify(message); - const escapedMessage = JSON.stringify(serializedMessage); + const escapedMessage = toAsciiJsSource(JSON.stringify(serializedMessage)); + return markPromiseAsHandled( sendCommand('Runtime.evaluate', { expression: `${RUNTIME_GLOBAL}.sendMessage(${JSON.stringify(domain)}, ${escapedMessage})`, - }).then(() => undefined), + }) + .then((response) => { + // The device rejecting the source text is reported on the response, + // so this is the only place a lost message can be noticed. Thrown + // rather than logged because the callers that matter await it: + // `bootstrap` retries the handshake, and an agent tool call turns it + // into an error for the client instead of an empty result. + const { exceptionDetails } = response as CDPEvaluateResponse; + if (exceptionDetails) { + throw new Error( + `Failed to send a message to the ${domain} domain: ` + + (exceptionDetails.text ?? 'unknown error'), + ); + } + }) + .then(() => undefined), ); }; diff --git a/packages/runtime/src/rn-devtools/__tests__/bindings-model.test.ts b/packages/runtime/src/rn-devtools/__tests__/bindings-model.test.ts index ead5e65f..8eb77892 100644 --- a/packages/runtime/src/rn-devtools/__tests__/bindings-model.test.ts +++ b/packages/runtime/src/rn-devtools/__tests__/bindings-model.test.ts @@ -28,7 +28,12 @@ vi.mock('../rn-devtools-frontend.js', () => { } return { - SDK: { SDKModel: { SDKModel: FakeSDKModel } }, + SDK: { + SDKModel: { SDKModel: FakeSDKModel }, + // `sendMessage` names `SDK.RuntimeModel.RuntimeModel` when asking the + // target for its runtime model; only the identity of that value matters. + RuntimeModel: { RuntimeModel: class FakeRuntimeModel {} }, + }, }; }); @@ -41,7 +46,7 @@ const BINDING_NAME = '__CHROME_DEVTOOLS_FRONTEND_BINDING__'; // keeping access typed instead of poking at an untyped `any`. type TestableModel = Pick< InstanceType, - 'subscribeToDomainMessages' | 'unsubscribeFromDomainMessages' + 'subscribeToDomainMessages' | 'unsubscribeFromDomainMessages' | 'sendMessage' > & { messagingBindingName: string | null; fuseboxDispatcherIsInitialized: boolean; @@ -133,3 +138,87 @@ describe('RozeniteBindingsModel bindingCalled', () => { ); }); }); + +const DISPATCHER_GLOBAL = '__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__'; + +/** + * Replays what the device does with the injected source text: compile it, + * call the dispatcher, and `JSON.parse` the payload it is handed. + */ +const evaluateOnDevice = (expression: string): Array<[string, unknown]> => { + const delivered: Array<[string, unknown]> = []; + const dispatcher = { + sendMessage: (domain: string, payload: string): void => { + delivered.push([domain, JSON.parse(payload)]); + }, + }; + + new Function(DISPATCHER_GLOBAL, expression)(dispatcher); + + return delivered; +}; + +describe('RozeniteBindingsModel sendMessage', () => { + const createSendingModel = (response: unknown, expressions: string[]): TestableModel => { + const model = new RozeniteBindingsModel({ + model: () => ({ + agent: { + invoke_evaluate: async (params: { expression: string }) => { + expressions.push(params.expression); + return response; + }, + }, + }), + } as never) as unknown as TestableModel; + model.fuseboxDispatcherIsInitialized = true; + return model; + }; + + it('keeps the injected expression ASCII-only so Hermes can compile it', async () => { + const message = { + pluginId: '@rozenite/storage-plugin', + payload: { value: 'ship \u{1F389} it \u{2014} \u{65E5}\u{672C}\u{8A9E}' }, + }; + const expressions: string[] = []; + + await createSendingModel({ result: { type: 'string' } }, expressions).sendMessage(message); + + // The bug this guards: Hermes compiles the expression as UTF-8 source and + // rejects a raw astral-plane code unit with `Invalid UTF-8 code point`, + // losing the message. V8 accepts that raw form, so this ASCII-only + // assertion is the part that actually reproduces the failure; the round + // trip below proves the escaping the fix adds does not alter the payload. + expect(expressions).toHaveLength(1); + expect(expressions[0]).toMatch(/^[\x20-\x7E]+$/); + expect(evaluateOnDevice(expressions[0])).toEqual([['rozenite', message]]); + }); + + it('reports a message the device refused to evaluate', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const expressions: string[] = []; + + // The DevTools frontend's generated `invoke_*` methods never reject, so + // this is the only shape in which a lost message can surface at all. + const model = createSendingModel( + { + exceptionDetails: { + exceptionId: 1, + text: 'Uncaught SyntaxError', + lineNumber: 0, + columnNumber: 0, + }, + result: { type: 'object' }, + }, + expressions, + ); + + await expect( + model.sendMessage({ pluginId: '@rozenite/storage-plugin' }), + ).resolves.toBeUndefined(); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError.mock.calls[0][0]).toContain('Uncaught SyntaxError'); + + consoleError.mockRestore(); + }); +}); diff --git a/packages/runtime/src/rn-devtools/bindings-model.ts b/packages/runtime/src/rn-devtools/bindings-model.ts index 334c19a7..97a7fc98 100644 --- a/packages/runtime/src/rn-devtools/bindings-model.ts +++ b/packages/runtime/src/rn-devtools/bindings-model.ts @@ -18,6 +18,30 @@ const DOMAIN_NAME = 'rozenite'; const MAIN_EXECUTION_CONTEXT_NAME = 'main'; const RUNTIME_GLOBAL = '__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__'; +/** + * Escapes every non-ASCII code unit as a `\uXXXX` escape sequence, so that the + * JS source text handed to `Runtime.evaluate` is pure ASCII. + * + * Hermes compiles that source text from UTF-8 and fails on a raw astral-plane + * code unit with `Invalid UTF-8 code point`, which loses the message without + * any visible error. The escape sequence it produces is plain ASCII, and the + * device's own parser turns it back into the original code unit, so the payload + * the device reconstructs is byte-identical to the one the host serialized. + * + * This has to run on the output of the second `JSON.stringify`, never before + * it: escaped earlier, `JSON.stringify` escapes the backslash instead and the + * device receives `\uD83C` as six characters of text. + * + * Identical in all three hosts that speak this protocol (here, the agent + * session in `@rozenite/middleware`, and the device connection in + * `@rozenite/app`), like the dispatcher-wait poll below. + */ +const toAsciiJsSource = (source: string): string => + source.replace( + /[^\0-\x7F]/g, + (codeUnit) => `\\u${codeUnit.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + export class RozeniteBindingsModel extends SDK.SDKModel.SDKModel { private messagingBindingName: string | null = null; private enabled = false; @@ -233,12 +257,26 @@ export class RozeniteBindingsModel extends SDK.SDKModel.SDKModel { } const serializedMessage = JSON.stringify(message); - const escapedMessage = JSON.stringify(serializedMessage); + const escapedMessage = toAsciiJsSource(JSON.stringify(serializedMessage)); // Note: Double quote must be used in case we get a string with a nested JSON object. - await runtimeModel.agent.invoke_evaluate({ + const response = await runtimeModel.agent.invoke_evaluate({ expression: `${RUNTIME_GLOBAL}.sendMessage('${DOMAIN_NAME}', ${escapedMessage})`, }); + + // The generated `invoke_*` methods never reject, so a `.catch()` would be + // dead code: a JS-level failure while evaluating reaches us here. Before + // it was dropped on the floor, so a message the device never received + // looked exactly like a message the device received. This host's only + // caller is the plugin-iframe relay in `plugin-view.ts`, which does not + // await, so this reports rather than throws: a rejected promise there + // would surface as an `unhandledrejection` in the DevTools page itself. + if (response.exceptionDetails) { + console.error( + `[rozenite] Failed to send a message to the ${DOMAIN_NAME} domain: ` + + response.exceptionDetails.text, + ); + } } async enable(): Promise {