fix(irc): chunk PRIVMSG on UTF-16 boundary to avoid lone surrogates#96572
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 5, 2026, 3:09 PM ET / 19:09 UTC. Summary PR surface: Source +11, Tests +178. Total +189 across 2 files. Reproducibility: yes. The reproduction path is a long IRC PRIVMSG such as Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land this focused surrogate-boundary fix after current-head checks are resolved, then rebase the IRC byte-budget PR over it so the final splitter enforces both delivery invariants. Do we have a high-confidence way to reproduce the issue? Yes. The reproduction path is a long IRC PRIVMSG such as Is this the best way to solve the issue? Yes. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 39b5bf38f746. Label changesLabel justifications:
Evidence reviewedPR surface: Source +11, Tests +178. Total +189 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (5 earlier review cycles)
|
4e945f0 to
1230b28
Compare
1230b28 to
8f18551
Compare
8f18551 to
d365ae9
Compare
|
Maintainer verification for final rebased head
Known gap: I did not repeat the public-network IRC run; my independent final-head proof used the real client over socket-level transport mocks in a sanitized AWS environment. #99138 is complementary UTF-8 wire-byte work, not a duplicate, and should rebase over this boundary fix. |
4aa2692 to
b4264c0
Compare
sendPrivmsg split long messages with String.slice on a UTF-16 code-unit index, so an emoji (or other astral character) straddling the split point was cut into a lone high/low surrogate, sending broken bytes in the PRIVMSG to the IRC server. Slice each chunk with sliceUtf16Safe so a surrogate pair is never split. When the budget is too small to fit even the leading astral character, emit that character whole so chunking still makes progress. Adds a socket-level test (fake net/tls socket) asserting no PRIVMSG chunk contains a lone surrogate, including the one-code-unit budget edge case, while the existing space-preferring split is preserved.
b4264c0 to
44f75d3
Compare
|
Merged via squash.
|
…penclaw#96572) * fix(irc): chunk PRIVMSG on UTF-16 boundary to avoid lone surrogates sendPrivmsg split long messages with String.slice on a UTF-16 code-unit index, so an emoji (or other astral character) straddling the split point was cut into a lone high/low surrogate, sending broken bytes in the PRIVMSG to the IRC server. Slice each chunk with sliceUtf16Safe so a surrogate pair is never split. When the budget is too small to fit even the leading astral character, emit that character whole so chunking still makes progress. Adds a socket-level test (fake net/tls socket) asserting no PRIVMSG chunk contains a lone surrogate, including the one-code-unit budget edge case, while the existing space-preferring split is preserved. * refactor(irc): make surrogate-safe chunking direct * fix(irc): preserve one-unit chunk limits --------- Co-authored-by: Peter Steinberger <[email protected]>
…void lone surrogates The Mattermost inbound handler in extensions/mattermost/src/mattermost/monitor.ts truncates the message body preview with bodyText.slice(0, 200), which can split a UTF-16 surrogate pair when an emoji (e.g. 🎉, 🦞) or other astral character lands at the 200-char boundary. The resulting lone surrogate half leaks into the verbose log preview and could propagate to downstream consumers that reject ill-formed strings. Replace the raw .slice(0, 200) with the shared truncateUtf16Safe helper (from openclaw/plugin-sdk/text-utility-runtime), which backs up one code unit when the cut would land inside a surrogate pair. This matches the same pattern already applied to the Slack, Discord, Telegram, IRC, Line, MS Teams, and Feishu channel previews (openclaw#96456, openclaw#96569, openclaw#96572, openclaw#96577, openclaw#96578, openclaw#97462, openclaw#97472, openclaw#98994). The preview is only used for the verbose inbound log line so the behavioral change is cosmetic: when the 200th code unit would split an emoji, the preview now includes one fewer code unit rather than emitting a broken surrogate.
…void lone surrogates The Mattermost inbound handler in extensions/mattermost/src/mattermost/monitor.ts truncates the message body preview with bodyText.slice(0, 200), which can split a UTF-16 surrogate pair when an emoji (e.g. 🎉, 🦞) or other astral character lands at the 200-char boundary. The resulting lone surrogate half leaks into the verbose log preview and could propagate to downstream consumers that reject ill-formed strings. Replace the raw .slice(0, 200) with the shared truncateUtf16Safe helper (from openclaw/plugin-sdk/text-utility-runtime), which backs up one code unit when the cut would land inside a surrogate pair. This matches the same pattern already applied to the Slack, Discord, Telegram, IRC, Line, MS Teams, and Feishu channel previews (openclaw#96456, openclaw#96569, openclaw#96572, openclaw#96577, openclaw#96578, openclaw#97462, openclaw#97472, openclaw#98994). The preview is only used for the verbose inbound log line so the behavioral change is cosmetic: when the 200th code unit would split an emoji, the preview now includes one fewer code unit rather than emitting a broken surrogate.
Summary: Long IRC PRIVMSG payloads were chunked on raw UTF-16 indices, so an emoji straddling a chunk boundary was torn into lone surrogates — malformed UTF-8 on the wire and a garbled glyph for the recipient. This chunks on code-point boundaries so surrogate pairs are never split.
What Problem This Solves
connectIrcClientinextensions/irc/src/client.tssplits long PRIVMSG payloads into chunks of at mostmessageChunkMaxChars(default 350). The split was a rawchunk.slice(0, splitAt)on the UTF-16 string. When the split index lands in the middle of an astral character — i.e. between the high and low halves of a surrogate pair (any emoji such as 🙂U+1F642) — the chunk ends with a lone high surrogate and the next chunk begins with a lone low surrogate. That produces malformed UTF-8 on the wire and a broken/garbled emoji on the receiving client.Trigger: a message of
"xxxxxxxxx🙂rest"withmessageChunkMaxChars = 10. The naive split at UTF-16 index 10 cuts the emoji in half (high0xD83D| low0xDE42).Fix
Add
sliceIrcPrivmsgChunk, which slices on a safe UTF-16 boundary via the sharedsliceUtf16Safehelper (openclaw/plugin-sdk/text-utility-runtime) so a surrogate pair is never split. Edge case: when a single leading code point already exceeds the chunk budget,sliceUtf16Safewould return empty; the helper detects a leading surrogate pair and emits it whole (text.slice(0, 2)) so chunking still advances instead of looping.messageChunkMaxCharsis also clamped to a saneMath.max(1, Math.floor(...)).Evidence
Real IRC send/receive E2E proof, using a local TCP IRC server process and real TCP clients. This is not a
node:net/node:tlsmock.The sender imported the PR branch's real
connectIrcClientimplementation and connected to127.0.0.1. A separate rawnet.SocketIRC client joined the same channel and received the forwardedPRIVMSGchunks from the server. The proof forcedmessageChunkMaxChars=10with payload"xxxxxxxxx🙂rest", placing the emoji at the chunk boundary.Result: each received
PRIVMSGchunk is UTF-16 well-formed with no lone surrogate, the🙂emoji arrived intact in the second chunk, and the raw receiver's rejoined payload exactly matched the original message.Tests
extensions/irc/src/client.surrogate.test.tsdrives the realconnectIrcClientover a mocked socket and asserts (red before the fix, green after):does not split an emoji surrogate pair across PRIVMSG chunks— emoji at the budget boundary is not torn; no chunk contains a lone surrogate.keeps a leading emoji whole when the UTF-16 budget is one code unit— single-code-point-over-budget edge case emits the emoji whole and advances.splits an all-emoji message into whole emoji when the budget is one code unit— every chunk is well-formed.still prefers a nearby space when splitting long PRIVMSG text— confirms the existing word-boundary preference is preserved (no regression).Each test runs the lone-surrogate regex
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/over every PRIVMSG body the client writes.