Skip to content

refactor(native): drop dead IsExceptionPending checks - #40

Merged
ThomasK33 merged 1 commit into
mainfrom
refactor/drop-dead-exception-checks
Oct 29, 2026
Merged

ThomasK33 merged 1 commit into
mainfrom
refactor/drop-dead-exception-checks

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: dead code in native/terminal.cc.

Six methods run if (env.IsExceptionPending()) return env.Undefined(); right after RequireTerminal(env). binding.gyp defines NAPI_CPP_EXCEPTIONS, so RequireTerminal reports a disposed terminal by throwing a C++ exception. That throw skips the check. When RequireTerminal returns, no JavaScript exception can be pending: before each check, only info.Env() and RequireTerminal run, and neither calls into JavaScript. So the checks never fire.

What changed

  • native/terminal.cc: I deleted the six checks in Feed, Resize, Snapshot, GetVisibleText, FormatPlain and FormatHtml.
  • native/README.md: I removed the clause about those checks. The part that stays true stays.

Scope

This is a behavior-preserving refactor. RequireTerminal and its use-after-dispose error do not change. The other three #12 leftovers are not in this PR.

Blast Radius

The change is limited to the six native methods. Callers see the same results and the same errors. No public API, type, or build setting changes.

Verification

Before (unchanged code at bf41459) and after (this branch) give the same counts:

Gate Before After
npm ci && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> npm run build:native && npm run build && npm run verify exit 0, 5 files, 67/67 passed exit 0, 5 files, 67/67 passed
same with the ReleaseSafe prefix exit 0, 5 files, 67/67 passed exit 0, 5 files, 67/67 passed
UBSan: rm -rf dist && ... CXXFLAGS="-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all" ... npm run build:native && npm test && npm run smoke exit 0, 5 files, 67/67 passed exit 0, 5 files, 67/67 passed
  • npx vitest run test/terminal.test.ts -t idempotent --reporter=verbose after the change: makes dispose idempotent and rejects use after dispose passed.
  • .agents/skills/verify-native-lifecycle/run.sh "$(mktemp -d)": exit 0, last line OK: every result matches EXPECT. The model treats RequireTerminal as the null guard, and that guard stays.

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

binding.gyp defines NAPI_CPP_EXCEPTIONS, so RequireTerminal throws a C++ exception and no JavaScript exception can be pending at these checks. The README no longer mentions them.

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

Copy link
Copy Markdown
Member Author

Verdict: PASS+NOTES
Head: f1a08fc

I checked this PR against its parent bf414593fa85a87f417f6efa76be9969410ffaee (current origin/main). I did not write it. All tests and scripts ran on Node v26.12.1 with Path unset (npm exec -c 'node -p process.version' printed v26.12.1, npx vitest --version printed vitest/4.1.12 linux-x64 node-v26.12.1, and build/config.gypi has "nodedir": "/home/coder/.cache/node-gyp/26.12.1" at both commits).

Scenario: the six methods, live and after dispose()

  • Command: node scenario.mjs <checkout>. It creates a 20x4 terminal through dist/index.js (public API) and through require("node-gyp-build")(<checkout>) (raw native object). It calls feed (string and Uint8Array), resize(30, 5), snapshot(), snapshot({includeScrollback:true,includeCells:true}), getVisibleText(), formatPlain() and formatHtml(), then dispose(), then all of them again. On the raw object it also sends bad arguments (feed(), feed(123), resize(0, 5), resize('a', 5), resize(1), snapshot(5), feed.call({}), getVisibleText.call({})) live and disposed. It prints one JSON line per call: the return value (or its sha256), or the error constructor, name and message.
  • Parent and head: 53 lines each, byte-identical, for the ReleaseFast, ReleaseSafe and UBSan builds (6 runs, 3 diffs, all empty).
  • Live: getVisibleText() returns "hi\nboldAB\n\n\n" and formatPlain() returns "hi\nboldAB" at both commits. After dispose(), 23 calls throw Error: GhosttyVtTerminal has been disposed at both commits. 8 are public calls, which src/index.ts rejects before native code runs. 15 are raw native calls, which reach the changed methods and get the error from RequireTerminal. This includes the 7 bad-argument calls, because RequireTerminal runs first. feed.call({}) throws TypeError: Illegal invocation at both commits.
  • The head binary really lost the checks. objdump -dr on build/Release/obj.target/libghostty_vt_node/native/terminal.o shows napi_is_exception_pending called from all six methods at the parent and from none of them at the head.

Gate (same steps at both commits, all exit 0)

  • ReleaseFast (lgv-fast): npm run verify. Head and parent: Test Files 5 passed (5), Tests 67 passed (67). Smoke passed, and its output is identical at both commits.
  • ReleaseSafe (lgv-safe): npm run verify. Head and parent: 5 files, 67 of 67 tests passed. Smoke passed.
  • UBSan, in the CI order (rm -rf dist, then build:native with -fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all, then npm test, then npm run smoke): 13 __ubsan_handle_*_abort references in the addon .o files at both commits. Head and parent: 5 files, 67 of 67 tests passed. Smoke passed.

Diff review

  • The diff removes exactly six lines from native/terminal.cc (in Feed, Resize, Snapshot, GetVisibleText, FormatPlain and FormatHtml) and shortens one sentence in native/README.md. It makes no other change.
  • The removed checks could not fire. Before each method body, node-addon-api 8.9.2 runs InstanceMethodCallbackWrapper, which calls napi_get_cb_info and napi_unwrap. Both throw a C++ Napi::Error on failure (NAPI_THROW_IF_FAILED), so the method body never runs. Inside each method, only info.Env() and RequireTerminal(env) run before the removed line. RequireTerminal either returns terminal_ with no N-API call, or throws a C++ Napi::Error, which skips the removed line. No JavaScript runs on either path.
  • binding.gyp defines NAPI_CPP_EXCEPTIONS, and napi.h maps it to NODE_ADDON_API_CPP_EXCEPTIONS, which makes WrapCallback catch the Napi::Error and throw it into JavaScript.
  • The README sentence that remains is true. The class has 8 instance methods. The 6 above call RequireTerminal first, encodeMouse calls it after reading its arguments, and dispose does not call it. The runtime error text matches. git grep -i IsExceptionPending at the head returns nothing.
  • The PR adds no test, which is expected for a dead-code removal. The identical scenario output and the unchanged test count stand in for one.

Note (not caused by this PR, not blocking)

  • native/README.md line 52 says raw feed throws TypeError("feed typed array must be Uint8Array") for a typed array that is not a Uint8Array. On Node v26.12.1 that does not happen at the parent or the head. The raw object accepts new Uint16Array([0x4241]) and new Float32Array(0), and the Uint16Array writes the bytes AB. A DataView throws Error: Invalid argument. The public feed is not affected: it throws TypeError: feed data must be a string, Buffer, or Uint8Array for a Uint16Array. My guess, which I did not verify, is that IsBuffer() returns true for every typed array on this Node version, so the IsTypedArray() branch never runs.

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

@ThomasK33
ThomasK33 merged commit 72e9db7 into main Oct 29, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the refactor/drop-dead-exception-checks branch October 29, 2026 23:26
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
The `verify-native-lifecycle` skill keeps a sync commit. At that commit, its TLA+ model describes the covered native code. The sync commit was still `7366b75` (#12). Since then, #40 to #43, #46, and #49 changed covered code. #46 promised this follow-up.

## What changed
- The sync commit moves to `c54051e`, the current main. It includes #48 and #49.
- The coverage rows now name `ghostty_terminal_set` (scrollback byte limit) in the constructor and its failure path, `Resize`'s second `ghostty_terminal_resize` call, and the `GHOSTTY_TERMINAL_DATA_MODE` read in `EncodeMouse`. Ghostty v1.4.0 (#43) brought these.
- The "no JS inside native methods" assumption now says how each method reads its arguments:
  1. `Snapshot` reads its options before `RequireTerminal`, and it builds its result with `DefineValue` and `DefineIndex`.
  2. `Feed` and `Resize` call `RequireTerminal` first. After that, they only test types and read strings, bytes, and numbers, which runs no JS.
  3. `GetVisibleText`, `FormatPlain`, and `FormatHtml` read no arguments.
  4. The assumption was false for `snapshot` until #46.
- The bullet that lists where caller JS can run now names public `feed`: `assertFeedData` runs `data instanceof Uint8Array`, which runs a Proxy's `getPrototypeOf` trap after `#assertUsable`. The native null check covers it, as it does for `snapshot`.
- `AddonEnvLifecycle.tla` changes in comments only. The `CreateFails` comment now names the `ghostty_terminal_set` failure too.

The model itself does not change. #48 changes only a test. #49 changes only `Feed`'s argument checks, so the `Use(t)` step still describes `feed`.

## Verification
- I checked each row against `native/terminal.cc` and `src/index.ts` at `c54051e`.
- `git diff 7366b75 c54051e` on the covered paths adds no new static state, `Napi::Reference`, or new place that frees `terminal_` or `mouse_encoder_`.
- `run.sh`: all 35 rows match EXPECT (`OK: every result matches EXPECT.`). The current config holds all 5 rules. Each of the 6 mutants breaks the rule it guards.

The verifier's two notes on `0ff0941` (the `feed` window and the `CreateFails` comment) are fixed in `584c561`. `run.sh` still passes all 35 rows.

Workspace 36beaed175 drafted the first version of this commit. I corrected the assumption bullet: it said that every method reads its arguments before `RequireTerminal`, but `Feed` and `Resize` do not.

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