Skip to content

fix(src): read each encodeMouse field once - #41

Merged
ThomasK33 merged 2 commits into
mainfrom
fix/mouse-read-fields-once
Oct 29, 2026
Merged

ThomasK33 merged 2 commits into
mainfrom
fix/mouse-read-fields-once

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The user wants four small leftovers from the mouse-encoding PR (#12) fixed and landed on main. This PR fixes one of them.

normalizeMouseOptions and normalizeMouseEvent in src/index.ts read some caller properties more than once: once to validate, again to copy. A getter can pass the TypeScript checks with one value and hand the addon another. The addon checks again, so memory stays safe. But the TypeScript layer then validates a value it does not forward.

On main with a ReleaseSafe addon, x is read twice and geometry.screenWidth three times. A getter that returns 3 and then 1e30 for x throws a RangeError for a value the caller's first read never produced. A screenWidth getter that returns 80 and then 0 passes the TypeScript check and fails in the addon.

What changed

  • Both normalizers destructure each caller field into a local at the top, including options.geometry, every geometry field, anyButtonPressed, trackLastCell, event.action, button, x, y, modifiers and its shift, ctrl, alt. They validate and forward only those locals.
  • Error types, messages and check order are unchanged.
  • A new test in the native suite feeds getters that change after the first read and expects the bytes for the first value.
  • src/README.md now states that each caller field is read once.

Scope

Only the double read in the two TypeScript normalizers. The other three #12 leftovers are separate PRs. native/ is unchanged.

Blast Radius

Callers with plain data objects see no change. Callers whose getters return different values on each read now get the first value checked and encoded. A getter that throws now throws when the normalizer destructures it, before any later field check. That changes which error such a caller sees, not the message of any check.

Verification

  • Test first, on commit 5d2aa49 (test only): npx vitest run test/terminal.test.ts -t "getters change" failed with RangeError: mouse x is outside Ghostty's cell or pixel coordinate range at normalizeMouseEvent src/index.ts:130.
  • Same command on 01f4576 (fix): 1 passed.
  • Repro script after the fix (ReleaseSafe addon, node repro.mjs). The R1 lines belong to another leftover and are the same as on main:
    R2 x getter (3, then 1e30): bytes 1b 5b 3c 30 3b 34 3b 34 4d
    R2 x reads: 1
    R2 geometry.screenWidth getter (80, then 0): bytes 1b 5b 3c 30 3b 34 3b 34 4d
    R2 screenWidth reads: 1
    
    On main the same script printed x reads: 2 and screenWidth reads: 3 and threw both errors.
  • npm ci && build:native (ReleaseFast) && npm run build && npm run verify: exit 0, 68 tests passed.
  • Same with ReleaseSafe: exit 0, 68 tests passed.
  • UBSan (rm -rf dist, CXXFLAGS=-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all, npm test && npm run smoke): exit 0, 68 tests passed.
  • .agents/skills/verify-native-lifecycle/run.sh: OK: every result matches EXPECT.

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

A getter can return one value to the TypeScript checks and another to
the addon. This test fails until the normalizers read each field once.

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: Id19dd86ea228c68d85b420216eaa2f4cb43d1621
The normalizers destructure each caller field into a local, then
validate and forward those locals. Error types, messages and check
order stay the same.

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: Ia074be12cc33f616d1762b28f4e8d514e0652039
@ThomasK33

Copy link
Copy Markdown
Member Author

Verdict: PASS+NOTES
Head: 01f4576

Parent: bf414593fa85a87f417f6efa76be9969410ffaee (merge base, equal to main). Every build and test ran on Node v26.12.1 with unset Path. npm exec -c 'node -p process.version' and a one-line test under npx vitest run both printed v26.12.1.

  • Scenario 1, repro. I ran node repro.mjs from each checkout root against dist/ with the ReleaseFast addon.
    • Parent: the x getter (3, then 1e30) threw RangeError: mouse x is outside Ghostty's cell or pixel coordinate range with x reads: 2. The geometry.screenWidth getter (80, then 0) threw the addon's RangeError: screenWidth must be a positive 32-bit integer with screenWidth reads: 3.
    • Head: both R2 cases print bytes 1b 5b 3c 30 3b 34 3b 34 4d, with x reads: 1 and screenWidth reads: 1. The head prints the same with the ReleaseSafe addon.
    • The R1 lines are identical at parent and head.
  • Scenario 2, plain objects. My script calls encodeMouse in SGR (1000+1006), any-motion (1003) and SGR-pixels (1016) modes. It covers press, release, motion with and without a button, anyButtonPressed, trackLastCell, modifiers, padding and pixel geometry, plus 54 invalid inputs. 15 of the invalid inputs set two bad fields at once, which pins the check order. It prints 100 lines: 46 byte results and 54 ErrorType: message results. diff of the parent output against the head output is empty.
  • Read counts. I wrapped all 19 caller fields in counting getters. These are the 5 event fields, the 3 modifier booleans, options.geometry, anyButtonPressed, trackLastCell and the 8 geometry fields. At head each field is read exactly once, and the bytes match the parent's. At the parent 10 fields are read more than once (event.button and event.modifiers 5 times, action, x, y and the four geometry sizes 3 times, options.geometry twice).
  • Gate at head.
    • ReleaseFast: npm ci, build:native, npm run build, npm run verify. Exit 0, 68 tests passed in 5 files. The parent has 67.
    • ReleaseSafe: same steps, exit 0, 68 passed.
    • UBSan in the CI order (rm -rf dist, -fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all, npm test && npm run smoke): exit 0, 68 passed, smoke printed its snapshot, and the logs have 0 runtime error lines. nm -u shows __ubsan references in addon.o, terminal.o and libghostty_vt_shim.o.
  • Revert test. At head I ran git checkout <parent> -- src/index.ts, then npx vitest run test/terminal.test.ts -t "getters change". The new test failed with RangeError: mouse x is outside Ghostty's cell or pixel coordinate range at normalizeMouseEvent src/index.ts:130. With the head file restored, it passed.
  • Diff. The PR changes the two normalizers in src/index.ts, adds one test and changes one README sentence. native/ is unchanged. The new README sentence matches the read counts above. Renaming modifiers to normalizedModifiers and dropping the ! assertions both follow from the destructuring. Read order changes only for getters, as the Blast Radius section says.

Note (non-blocking):

  • The new test covers 2 of the 19 fields (x and geometry.screenWidth). If a later change reads another field twice, such as y or cellWidth, the test still passes. A test that wraps every field in a counting getter and expects one read each would guard the whole README sentence.

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

@ThomasK33
ThomasK33 merged commit 30242de into main Oct 29, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the fix/mouse-read-fields-once 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>
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
The goal is to handle and land the items still open after the #12 follow-ups, one PR each. This PR adds the test coverage for the read-once contract that #41 added.

`src/README.md` promises that `encodeMouse` reads each caller field once. The #41 test checks only `x` and `geometry.screenWidth`. A later change that reads `y` or `cellWidth` twice still passes it.

## What changed
`test/terminal.test.ts` has one new native test, "reads every event, modifier, option, and geometry field once", right after the #41 test.

1. The test puts a counting getter on all 19 caller fields. Every optional field is set to a valid value, so each one is present.
2. It calls `encodeMouse` once and checks the bytes: `ESC[<12;4;6M`. The SGR rule gives this value: left button 0, plus 4 for shift, plus 8 for alt. Column and row are 1-based (x 3 → 4, y 5 → 6).
3. It checks the read counts as one equality on a map of field name to count. A failure names each field read more than once.

## Scope
Test only. No change to `src/` or `native/`.

## Blast Radius
One test in the native suite. It skips with the rest of the suite when the addon is not built.

## Verification
I proved the test can fail. I put the parent of #41's fix (`git show 30242de^:src/index.ts`) into `src/index.ts`, rebuilt, and ran the test. It fails and names the 10 fields read more than once:

```
$ npx vitest run test/terminal.test.ts -t "reads every"
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > reads every event, modifier, option, and geometry field once
AssertionError: expected { screenWidth: 3, …(18) } to deeply equal { geometry: 1, …(18) }
-   "action": 1,
+   "action": 3,
-   "button": 1,
-   "cellHeight": 1,
-   "cellWidth": 1,
+   "button": 5,
+   "cellHeight": 3,
+   "cellWidth": 3,
-   "geometry": 1,
-   "modifiers": 1,
+   "geometry": 2,
+   "modifiers": 5,
-   "screenHeight": 1,
-   "screenWidth": 1,
+   "screenHeight": 3,
+   "screenWidth": 3,
-   "x": 1,
-   "y": 1,
+   "x": 3,
+   "y": 3,
      Tests  1 failed | 33 skipped (34)
```

The other 9 fields showed 1 read each. Then I restored `src/index.ts`, rebuilt, and ran the test again:

```
$ npx vitest run test/terminal.test.ts -t "reads every"
 Test Files  1 passed (1)
      Tests  1 passed | 33 skipped (34)
```

Gates on the final commit. Each one exited 0:

1. `npm ci && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> npm run build:native && npm run build && npm run verify`. 78 tests passed.
2. The same with the ReleaseSafe prefix. 78 tests passed.
3. UBSan: `rm -rf dist && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> CXXFLAGS="-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all" LDFLAGS="-fsanitize=undefined,float-cast-overflow" npm run build:native && npm test && npm run smoke`. 78 tests passed, smoke passed.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_
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