Files
software-mansion__argent/packages/tool-server/test/ndjson-socket.test.ts
filip131311 e7c6f3324c fix(tool-server): frame device sockets on \n only — U+2028 in a label stalled describe for 15 s (#975)
## Problem

`describe` returned an empty tree after ~15 s whenever the screen had an
accessibility label containing U+2028 (LINE SEPARATOR) — e.g. any
Element/Matrix reply preview, or rich text pasted from the web. Nothing
was wrong on the device: ax-service answered in ~170 ms with a full,
valid tree.

The tool-server framed the ax-service socket with
`readline.createInterface`, which splits on the four ECMAScript line
terminators (`\n`, `\r`, **U+2028, U+2029**). JSON permits U+2028
unescaped inside a string and `NSJSONSerialization` emits it raw, so one
valid frame became three fragments, none parsed, each was dropped by
`catch { return }`, and the pending RPC waited out its 10 s timeout —
then the native-devtools fallback waited out its 5 s. On top of that,
the timeout was reported as *"this simulator was not booted through
argent … call boot-device with force=true"*, sending the agent to reboot
a healthy simulator.

Minimal repro (no device): `JSON.stringify({label:"a
b"})+"\n"` through
`readline` → 2 lines, 0 parsed.

## Fix

- **`utils/ndjson-socket.ts`** (new): `attachNdjsonReader` frames on the
`\n` byte only (`0x0A` can never occur inside a multi-byte UTF-8
sequence, so this is exact) and reports every unparseable frame to
stderr instead of swallowing it.
- Wired into the three device-facing JSON sockets: `ax-service.ts`,
`native-devtools.ts`, `android-devtools-client.ts` (the Android helper's
`org.json` and the iOS dylib's `NSJSONSerialization` both emit U+2028
raw, so all three were exposed). The two remaining `readline` uses read
process **stdout** for readiness markers — no JSON, no user text — and
are left alone.
- **`describe/platforms/ios/index.ts`**: a throw from `axApi.describe()`
(timeout, RPC error) now carries the error message in the hint and uses
the resolved api's real `degraded` flag, instead of being reported as
the boot caveat. Separate commit so it can be reverted independently.

## Verification

- 6 new unit tests for the reader (U+2028/U+2029 in a string, chunk
split mid-multibyte char, several frames per chunk, CRLF/empty lines,
dropped-frame reporting, trailing frame on `end`) and 3 for the hint
(`describe-tool.test.ts`). Full tool-server suite: 4816 passed.
- Live A/B on iPhone 16 / iOS 18.5, Safari page with `<meta
charset=utf-8>` and a U+2028 inside a `<p>`:
- argent 0.22.1 (`main`): `describe` **12.6 s**, `source:
native-devtools`, web text missing, false "not booted through argent"
hint.
- This branch (`dist` against installed binaries): **50 ms**, `source:
ax-service`, label `"In reply to Dave
Loving it so far"` present, no
hint, 0 dropped frames logged.

## Docs

No docs update needed — no tool, CLI, config or flow surface changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01VwqqHRhfuHXbKr6LYskxhJ
2026-08-28 17:07:15 +02:00

77 lines
2.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { PassThrough } from "node:stream";
import type * as net from "node:net";
import { attachNdjsonReader } from "../src/utils/ndjson-socket";
// A PassThrough stands in for the net.Socket: the reader only needs
// setEncoding / on("data") / on("end").
function harness() {
const stream = new PassThrough();
const messages: unknown[] = [];
const dropped: { bytes: number; preview: string }[] = [];
attachNdjsonReader(stream as unknown as net.Socket, {
onMessage: (m) => messages.push(m),
onDropped: (d) => dropped.push(d),
});
return { stream, messages, dropped };
}
const settle = () => new Promise((r) => setImmediate(r));
describe("attachNdjsonReader", () => {
it("keeps U+2028 / U+2029 inside a string in one frame (the Element stall)", async () => {
const { stream, messages, dropped } = harness();
const label = "In reply to Dave\u2028Loving it so far\u2029next";
stream.write(JSON.stringify({ id: 1, result: { label } }) + "\n");
await settle();
expect(messages).toEqual([{ id: 1, result: { label } }]);
expect(dropped).toEqual([]);
});
it("reassembles a frame split across chunks, including mid multi-byte character", async () => {
const { stream, messages } = harness();
const bytes = Buffer.from(JSON.stringify({ id: 2, label: "café\u2028ünïcode" }) + "\n", "utf8");
// Cut inside the 3-byte U+2028 sequence.
const cut = bytes.indexOf(Buffer.from("\u2028", "utf8")) + 1;
stream.write(bytes.subarray(0, cut));
await settle();
expect(messages).toEqual([]);
stream.write(bytes.subarray(cut));
await settle();
expect(messages).toEqual([{ id: 2, label: "café\u2028ünïcode" }]);
});
it("delivers several frames arriving in one chunk, in order", async () => {
const { stream, messages } = harness();
stream.write('{"id":1}\n{"id":2}\n{"id":3}\n');
await settle();
expect(messages.map((m) => (m as { id: number }).id)).toEqual([1, 2, 3]);
});
it("skips empty lines and tolerates CRLF", async () => {
const { stream, messages, dropped } = harness();
stream.write('\n{"id":1}\r\n\r\n{"id":2}\n');
await settle();
expect(messages).toEqual([{ id: 1 }, { id: 2 }]);
expect(dropped).toEqual([]);
});
it("reports an unparseable frame with byte length and a sanitised preview, then continues", async () => {
const { stream, messages, dropped } = harness();
stream.write("garbage\there\n" + '{"id":9}\n');
await settle();
expect(messages).toEqual([{ id: 9 }]);
expect(dropped).toHaveLength(1);
expect(dropped[0]!.bytes).toBe(Buffer.byteLength("garbage\there"));
expect(dropped[0]!.preview).toBe("garbage·here");
});
it("delivers a trailing frame without newline when the stream ends", async () => {
const { stream, messages } = harness();
stream.write('{"id":7}');
stream.end();
await settle();
expect(messages).toEqual([{ id: 7 }]);
});
});