Skip to content

fix(realtime): a body cut mid-emoji must not roll back the message - #185

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/outbox-lone-surrogate
Sep 5, 2026
Merged

fix(realtime): a body cut mid-emoji must not roll back the message#185
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/outbox-lone-surrogate

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

Quoting a message that contains an emoji can destroy the reply and 500.

The quote path truncates by UTF-16 code unit:

body: qr[0].body.slice(0, 240),      // server/src/api/router.ts:4133

If the 240th code unit is half of a non-BMP character, the result ends in a lone surrogate. JSON.stringify renders that as the literal ASCII escape \ud83d, which survives node-postgres' parameter encoding byte-for-byte — and Postgres refuses it:

ERROR:  invalid input syntax for type json
DETAIL: Unicode low surrogate must follow a high surrogate.

enqueueBroadcast runs inside the caller's transaction, so this doesn't just drop a notification. The ROLLBACK at router.ts:4189 discards the sender's messages row, the conversation_counters bump and the conversations.updated_at touch, and the request 500s. Every retry fails the same way, so one emoji-bearing message becomes permanently un-quotable — for humans through the API and for agents through cumora send --quote (server/src/agents/cli.ts:2176, same slice(0, 240)).

Reproduced

Postgres 16, through enqueueBroadcast itself, with exactly the string router.ts:4133 produces:

quoted body ends with charCode 0xd83d
ENQUEUE FAILED  code=22P02
  message: invalid input syntax for type json
  detail : Unicode low surrogate must follow a high surrogate.

And the asymmetry that makes it a lost write rather than a cosmetic one:

SELECT '{"body":"hi \ud83d"}'::jsonb;   -- ERROR: invalid input syntax for type json
SELECT length('hi \ud83d');             -- 9

messages.body is TEXT and takes the same bytes happily. Only the outbox half dies, and it takes the transaction with it.

Why this is new in 0.14

git diff v0.13.2..main -- server/src/api/router.ts shows the same payload used to go out as

-      await publish(CH_MESSAGE_NEW, {
+      await enqueueBroadcast(client, CH_MESSAGE_NEW, {

redis.publish happened after COMMIT and took any string, so a lone surrogate was a glyph that rendered as somewhere. Behind a ::jsonb cast inside the transaction it became a rollback. This is precisely the ambiguity docs/decisions/0001-transactional-realtime-outbox.md set out to remove — the commit is meant to be the only success boundary — so it seemed worth fixing at that boundary rather than at the caller.

The fix

Scrub at the cast, not at the 24 enqueueBroadcast call sites across 8 files, so fields added to future events are covered without anyone remembering to.

The helper already exists and its own doc comment names this exact cause:

/** Strip UNPAIRED UTF-16 surrogates. A string sliced mid-emoji (e.g.
 *  `body.slice(0, N)` cutting a surrogate pair) leaves a lone high/low surrogate. 

It was written for the model boundary (cli.ts, computer/engine.ts) and had simply never been pointed at this one. A JSON.stringify replacer visits every string in the tree, so nesting and arrays need no traversal of our own — worth noting because scrubbing the serialized text would not work: by then the surrogate is already the ASCII escape.

Verification

  • Reproduced and fixed against a real Postgres 16, through the product code path rather than a mock.
  • Three regression tests in realtime-outbox.test.ts. Two fail without the fix, with the production error:
    not ok 4 - [integration] a payload truncated mid-emoji still enqueues
      error: 'invalid input syntax for type json'
    not ok 5 - [integration] a lone surrogate anywhere in the tree is scrubbed, not just at the top
      error: 'invalid input syntax for type json'
    
  • The third pins the opposite direction — a well-formed emoji must survive intact — and passes both with and without the fix, so over-scrubbing would be caught too.
  • The mid-emoji test also asserts the readable half is kept: 239 characters survive, only the broken byte pair goes.
  • Unit suite: zero new failures against main. tsc --noEmit, biome lint ., all three source guards clean.

Not changed

The quote card is still cut at 240 code units, so a truncated body can now end one character short of a glyph instead of showing a broken one. Fixing the truncation to be grapheme-aware is a display question, separate from the lost write, and I left it alone.

The quote path truncates by UTF-16 code unit — `qr[0].body.slice(0, 240)`
— so quoting a message whose 240th code unit is half of a non-BMP
character yields a lone surrogate. JSON.stringify emits that as the
literal ASCII escape \ud83d, which survives transport byte-for-byte and
is then refused by the outbox's cast:

  ERROR:  invalid input syntax for type json
  DETAIL: Unicode low surrogate must follow a high surrogate.

enqueueBroadcast runs INSIDE the caller's transaction, so the failure
takes the reply with it: ROLLBACK discards the message row, the sequence
bump and the updated_at touch, and the request 500s. Every retry fails
identically, so one emoji-bearing message becomes permanently unquotable
for every human and every agent in the workspace.

This is new in 0.14. The same payload used to go to redis.publish AFTER
COMMIT, where a lone surrogate was a cosmetic glyph. Moving it behind a
::jsonb cast inside the transaction turned that into a lost write.
messages.body is TEXT and accepts the same bytes, which is why only the
outbox half dies.

Reproduced against Postgres 16 through enqueueBroadcast itself:
SQLSTATE 22P02 on exactly the string router.ts:4133 produces.

Scrubbed at the cast rather than at the 24 call sites, so fields added to
future events are covered too. The repo already owns the helper —
stripLoneSurrogates, whose own comment names body.slice(0, N) as the
cause — it had simply never been applied to this boundary. A
JSON.stringify replacer visits every string in the tree, so nesting and
arrays need no traversal.

The regression tests fail with the production error when the scrub is
removed. One of them pins the other direction: a well-formed emoji has
to survive intact.
@yetone
yetone merged commit 2f90341 into yetone:main Sep 5, 2026
18 of 21 checks passed
@yetone yetone mentioned this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants