Skip to content

fix(native): accept UTF-8 mouse rows above U+FFFF - #42

Merged
ThomasK33 merged 2 commits into
mainfrom
fix/utf8-mouse-rows
Oct 29, 2026
Merged

ThomasK33 merged 2 commits into
mainfrom
fix/utf8-mouse-rows

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

This is one of four small fixes left over from the mouse-encoding PR (#12).

With UTF-8 mouse mode (1005) set, encodeMouse throws RangeError for rows 65503 to 65534. Ghostty encodes those rows as valid 4-byte UTF-8. The binding rejected them because a comment claimed Ghostty adds 33 to the row in u16 arithmetic. That is false. point.Coordinate.y is u32 (src/terminal/point.zig:89), and src/input/mouse_encode.zig:142 computes cell.y + 33 in u32 before the u21 cast. The row cannot wrap. Only a UTF-16 surrogate reaches utf8Encode's catch unreachable.

What changed

  • native/terminal.cc: ValidateUtf8MouseCoordinate loses its is_row parameter and the codepoint > 65535 rule. The surrogate rule still applies to both axes. The comment now states the real constraint.
  • test/mouse-safety.test.ts: the [0, 65503] RangeError case is now a positive check. Rows 65503 and 65534 must encode as ESC [ M, 0x20, 0x21, then U+10000 (F0 90 80 80) and U+1001F (F0 90 80 9F). The expected bytes come from the protocol: x=0 is column 1 (code point 33), y=65503 is row 65504 (code point 65536). Every surrogate case stays.
  • README.md and native/README.md: the 16-bit row claim is gone. The surrogate statements stay.

Scope

Only the UTF-8 row rule. The other three #12 follow-ups are separate PRs.

Blast Radius

Callers that use mode 1005 on a grid taller than 65502 rows now get bytes instead of a RangeError for the last 32 rows. Surrogate cells still throw on both axes. Other mouse formats are unaffected.

Verification

All runs used Node v26.12.1.

Failing at the test commit (724f98b), ReleaseSafe addon, npx vitest run test/mouse-safety.test.ts:

     × rejects unsafe UTF-8 codepoints without terminating the process
     × rejects unsafe UTF-8 codepoints without terminating the process
+ RangeError: mouse y is unsafe for Ghostty's UTF-8 mouse encoding
      Tests  2 failed | 10 passed (12)

Passing at the fix commit (b5712d7):

 ✓ test/mouse-safety.test.ts > public mouse safety > rejects unsafe UTF-8 codepoints without terminating the process
 ✓ test/mouse-safety.test.ts > native mouse safety > rejects unsafe UTF-8 codepoints without terminating the process
      Tests  12 passed (12)

Repro script on the ReleaseSafe addon, before the fix:

R1 utf8 row y=65502: bytes 1b 5b 4d 20 21 ef bf bf
R1 utf8 row y=65503: RangeError: mouse y is unsafe for Ghostty's UTF-8 mouse encoding
R1 utf8 row y=65534: RangeError: mouse y is unsafe for Ghostty's UTF-8 mouse encoding

After the fix:

R1 utf8 row y=65502: bytes 1b 5b 4d 20 21 ef bf bf
R1 utf8 row y=65503: bytes 1b 5b 4d 20 21 f0 90 80 80
R1 utf8 row y=65534: bytes 1b 5b 4d 20 21 f0 90 80 9f

Gates, each exit 0:

  1. npm ci && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> npm run build:native && npm run build && npm run verify: 67 tests passed.
  2. The same with the ReleaseSafe prefix: 67 tests passed.
  3. UBSan (-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all, dist/ deleted) then npm test && npm run smoke: 67 tests passed, smoke passed.
  4. .agents/skills/verify-native-lifecycle/run.sh: OK: every result matches EXPECT.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

Ghostty computes the row code point in u32, so rows 65503 and 65534
are valid 4-byte UTF-8. The binding still rejects them, so this fails.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_

Change-Id: I8829cfec1c0e4c9792417c3219434367703453f1
Ghostty's row is u32, so cell.y + 33 cannot wrap. Only a surrogate
reaches utf8Encode's catch unreachable, so keep that rule alone for both
axes and drop the false 16-bit claim from the docs.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_

Change-Id: Ib9c1ea32b54dd1646a37fc2fbf01f99a052c8bcb
@ThomasK33

Copy link
Copy Markdown
Member Author

Verdict: PASS
Head: b5712d7

Parent: bf41459. I ran every scenario on Node v26.12.1 against the ReleaseSafe libghostty-vt prefix (Ghostty 48ccec1), each batch in a child process. A signal or exit 134 counted as FAIL.

  • Source. point.Coordinate.y is u32 (src/terminal/point.zig:89). Its value comes from GridSize.Unit (u16) after clamping, so the row index is at most 65534 and cell.y + 33 is at most 65567 (U+1001F) before the u21 cast (src/input/mouse_encode.zig:142). In that range utf8Encode fails only for surrogates.
  • Scenario 1. node .../followups/repro.mjs from each checkout root. Parent: R1 utf8 row y=65503: RangeError: mouse y is unsafe for Ghostty's UTF-8 mouse encoding, and the same for y=65534. Head: R1 utf8 row y=65503: bytes 1b 5b 4d 20 21 f0 90 80 80 and R1 utf8 row y=65534: bytes 1b 5b 4d 20 21 f0 90 80 9f. y=65502 gives ef bf bf at both commits. The R2 lines are identical at both commits.
  • Scenario 2. Rows 65400 to 65534 plus 65535, 65535.9, 70000 and 1e6. Press, release and motion (button left, anyButtonPressed: true). Public API (dist/index.js) and the raw native object. Modes ?1000h?1005h and also ?1003h?1005h, so motion and off-screen presses reach the encoder. Result: 24 child batches of 139 calls, all exit 0, no signal, 0 mismatches against a model of the pinned encoder.
    • Parent, per batch: 103 buffers, 36 RangeError (34 from the UTF-8 row rule, 2 from the coordinate range check).
    • Head, per batch: 137 buffers, 2 RangeError (70000 and 1e6: mouse y is outside Ghostty's cell or pixel coordinate range). Mode 1000 motion returns an empty buffer at both commits, because Ghostty's normal mode drops motion.
    • Bytes I derived by hand from the UTF-8 rule matched on both surfaces: row 65400 1b 5b 4d 20 21 ef be 99, row 65520 ... f0 90 80 91, release at 65535 1b 5b 4d 23 21 f0 90 80 9f, mode 1003 motion at 65535.9 1b 5b 4d 40 21 f0 90 80 9f.
  • Scenario 3. Surrogate cells 55263 to 57310 on each axis (65535x65535 geometry), press, release and motion, both surfaces. Head: 12288 of 12288 calls throw RangeError per axis (mouse y is unsafe ..., mouse x is unsafe ...), with no abort. The neighbors 55262 and 57311 encode as ed 9f bf and ee 80 80.
  • Abort control. I disabled the surrogate check in a scratch copy of the head and rebuilt against ReleaseSafe. press x=0 y=55263 and press x=55263 y=0 exited 134 with panic: attempt to unwrap error: Utf8CannotEncodeSurrogateHalf. release x=0 y=65534 returned 1b 5b 4d 23 21 f0 90 80 9f. So the harness detects aborts, and the surrogate rule that stays is the one Ghostty needs.
  • Revert test. At the head with native/terminal.cc from the parent, npx vitest run test/mouse-safety.test.ts gave Tests 2 failed | 10 passed (12). Both failures are rejects unsafe UTF-8 codepoints without terminating the process (public and native), with RangeError: mouse y is unsafe for Ghostty's UTF-8 mouse encoding. I restored the head file afterward.
  • Gate at head. ReleaseFast npm ci && build:native && npm run build && npm run verify: exit 0, 67 tests passed, smoke passed. ReleaseSafe, same commands: exit 0, 67 passed, smoke passed. UBSan in CI order (rm -rf dist, sanitizer build:native, npm test, npm run smoke): exit 0, 67 passed, smoke passed. nm -u on terminal.o lists 7 __ubsan_handle symbols. PR CI: all four ci rows pass.
  • Diff. It changes only the row rule, its comment, one test case and the two README sentences. git grep finds no other statement of the 16-bit row limit.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

@ThomasK33
ThomasK33 merged commit 8e3d303 into main Oct 29, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the fix/utf8-mouse-rows branch October 29, 2026 23:26
@ThomasK33 ThomasK33 mentioned this pull request Oct 30, 2026
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
encodeMouse first ships in this release, so #41 and #42 did not fix a
released bug. Their behavior now reads as part of the new API.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

Change-Id: Ic6de3b1b21ac87d8274619c74fd8121ef0e333e0
Signed-off-by: Thomas Kosiewski <tk@coder.com>
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.

1 participant