oficina / meet Public

chat: the composer counts the two separators Go escapes

Codex S4-7 (P2), and the last of W4-1. U+2028 and U+2029 are the one
place the two languages disagree about what "the JSON escaping of this
string" means: Go's encoder escapes them unconditionally —
`SetEscapeHTML(false)` included — and JavaScript's `JSON.stringify`
leaves them literal. Three bytes on the wire as themselves, six once Go
has written them.

So 2045 a's and one U+2028 counted 2048 in the composer and 2051 at the
server: the panel accepted the draft, the server refused it 413, and
"Could not send. Try again." was untrue advice — the retry could never
work. A paste of anything with a line separator in it (some editors,
some PDFs, plenty of the web) lands exactly there.

`chatBytes` escapes them the way Go does. The pattern is written as
escapes rather than as the characters themselves, because both are line
terminators in JavaScript source and a literal one inside a regular
expression is a syntax error — which is most of why this gap existed.

Client test seen failing first (6 !== 3, both characters, both sides of
the boundary), and five matching cases on the server, sent as
themselves the way a paste arrives. The server was always right here;
what the Go cases add is the agreement, pinned from both ends.

node --check, 198 node tests, make ci.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev bb97a15b6499e5cc6a63b8f93832143710e724ea parent 5546a8d
3 files changed, +64 −4
  • internal/meet/static/chat.js +16 −1
  • internal/meettest/chat_test.go +17 −0
  • test/browser/chat.test.mjs +31 −3
diff --git a/internal/meet/static/chat.js b/internal/meet/static/chat.js
index 06bcb02..47c4e42 100644
--- a/internal/meet/static/chat.js
+++ b/internal/meet/static/chat.js
@@ -60,8 +60,23 @@
// panel had no way to apply (whole-branch review, W4-1). Angle
// brackets and ampersands are not escaped on either side — SSE data is
// not HTML — so they cost one byte each here too.
+ // U+2028 and U+2029 are the one place the two languages disagree
+ // about what "the JSON escaping of this string" is: Go's encoder
+ // escapes them unconditionally — SetEscapeHTML(false) included —
+ // and JavaScript leaves them literal. Three bytes here, six there,
+ // and a draft of 2045 a's and one U+2028 counted 2048 in the composer
+ // and 2051 at the server, so the panel accepted a message that came
+ // back 413 with nothing to say about it (Codex S4-7). The counter
+ // measures what the server will measure, so it escapes them too.
+ //
+ // Written as escapes rather than as themselves: both characters are
+ // line terminators in JavaScript source, and a literal one inside a
+ // regular expression is a syntax error.
+ const GO_ESCAPES = /[\u2028\u2029]/g;
+
function chatBytes(text) {
- const s = JSON.stringify(String(text == null ? '' : text));
+ const s = JSON.stringify(String(text == null ? '' : text))
+ .replace(GO_ESCAPES, (c) => (c === '\u2028' ? '\\u2028' : '\\u2029'));
return new TextEncoder().encode(s).length - 2;
}
diff --git a/internal/meettest/chat_test.go b/internal/meettest/chat_test.go
index 8340817..c0f25d4 100644
--- a/internal/meettest/chat_test.go
+++ b/internal/meettest/chat_test.go
@@ -420,6 +420,23 @@ func TestChatCapCountsTheEscapedText(t *testing.T) {
strings.Repeat("a", chatCap-23) + strings.Repeat(`\"`, 23), http.StatusRequestEntityTooLarge},
{"angle brackets cost one byte each, not six",
strings.Repeat("<", chatCap), http.StatusOK},
+ // Codex S4-7: the two characters Go escapes even with
+ // SetEscapeHTML(false), and JavaScript's JSON.stringify leaves
+ // literal. Three bytes on the wire as themselves, six once Go
+ // has written them, and the composer used to count the first
+ // number while the server counted the second — so a draft the
+ // panel accepted came back 413. Sent here as themselves, which
+ // is how a paste arrives.
+ {"a line separator at the boundary",
+ strings.Repeat("a", chatCap-6) + "\u2028", http.StatusOK},
+ {"a line separator one byte past it",
+ strings.Repeat("a", chatCap-5) + "\u2028", http.StatusRequestEntityTooLarge},
+ {"a paragraph separator at the boundary",
+ strings.Repeat("a", chatCap-6) + "\u2029", http.StatusOK},
+ {"a paragraph separator one byte past it",
+ strings.Repeat("a", chatCap-5) + "\u2029", http.StatusRequestEntityTooLarge},
+ {"the draft the composer used to accept",
+ strings.Repeat("a", 2045) + "\u2028", http.StatusRequestEntityTooLarge},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs
index c7401d4..e0f5a0f 100644
--- a/test/browser/chat.test.mjs
+++ b/test/browser/chat.test.mjs
@@ -83,10 +83,18 @@ test('the shipped limits and sentences are the ones everything else was written
assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
});
-const build = (name) => new Function(lift(src, name) + '; return ' + name + ';')();
+const build = (name, prelude) =>
+ new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')();
const chatLine = build('chatLine');
const trimLog = build('trimLog');
-const chatBytes = build('chatBytes');
+
+// chatBytes closes over the pattern of what Go escapes and JavaScript
+// does not, which is read out of the source rather than retyped — a
+// counter tested against a pattern of the test's own would prove the
+// test.
+const goEscapes = /const GO_ESCAPES = (\/\[[^/]*\]\/g);/.exec(src);
+assert.notEqual(goEscapes, null, 'GO_ESCAPES is not a regular expression in chat.js any more');
+const chatBytes = build('chatBytes', 'const GO_ESCAPES = ' + goEscapes[1] + ';\n');
// mount closes over the module's constants and its three helpers, so
// the harness hands it exactly those and nothing else. `document` goes
@@ -100,7 +108,8 @@ const mount = new Function(
'SEND_FAILED',
'TOO_LONG',
'document',
- [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') +
+ 'const GO_ESCAPES = ' + goEscapes[1] + ';\n' +
+ [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') +
'\nreturn mount;',
)(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined);
@@ -354,6 +363,25 @@ test('the counter measures the text as the wire will carry it', () => {
'one byte past it, and the panel must say so before the server does');
});
+test('the two separators Go escapes and JSON.stringify does not are counted its way', () => {
+ // Codex S4-7. U+2028 and U+2029 are the one place the two languages
+ // disagree about what "the JSON escaping of this string" means: Go's
+ // encoder escapes them unconditionally, SetEscapeHTML(false) included,
+ // and JavaScript leaves them literal. Three bytes here, six there —
+ // so 2045 a's and one U+2028 counted 2048 in the composer and 2051 at
+ // the server, and the draft the panel accepted came back 413 with no
+ // way to say why.
+ for (const sep of ['\u2028', '\u2029']) {
+ assert.equal(chatBytes(sep), 6, JSON.stringify(sep) + ' is six bytes on the wire');
+ assert.equal(chatBytes('a' + sep + 'b'), 8);
+ }
+ // The boundary Codex measured, from both sides of it.
+ assert.equal(chatBytes('a'.repeat(MAX_BYTES - 6) + '\u2028'), MAX_BYTES);
+ assert.equal(chatBytes('a'.repeat(MAX_BYTES - 5) + '\u2029'), MAX_BYTES + 1);
+ assert.ok(chatBytes('a'.repeat(2045) + '\u2028') > MAX_BYTES,
+ "the composer accepted a draft the server refuses");
+});
+
test('maxlength is not the limit: 2048 characters can be far more than 2048 bytes', () => {
// Exactly at the cap in ASCII, and the control would allow it.
assert.equal(chatBytes('a'.repeat(MAX_BYTES)), MAX_BYTES);