Skip to content

(terminal): move node-pty to 1.2.0-beta.15 (#409) - #431

Merged
devsuitup merged 3 commits into
mainfrom
deps/409-node-pty-beta
Oct 3, 2026
Merged

devsuitup merged 3 commits into
mainfrom
deps/409-node-pty-beta

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Moves node-pty from ^1.0.0 (1.1.0 installed) to the exact pre-release 1.2.0-beta.15, for microsoft/node-pty#922. On Windows, the shell's exit thread could erase and free an entry of the native handle table while the main thread was still inside PtyKill / PtyResize. That is a second way, separate from #405, for the app to die from a heap corruption. 1.1.0 has no lock there; the beta takes g_ptyHandlesMutex around every handle-table access (src/win/conpty.cc). The maintainer decided to ship the beta (npm view node-pty dist-tags: beta 1.2.0-beta.15, latest 1.1.0).

The beta's OpenConsole also asks the terminal for its cursor position (CSI 6n), which 1.1.0 never did. A hidden or detached session cannot answer, and replaying the query later makes xterm.js send a reply OpenConsole types into Claude or the shell. The second and third commits drop the query from both replay buffers.

Closes #409

What changed

  • package.json: "node-pty": "1.2.0-beta.15", pinned, no caret.
  • package-lock.json: the node_modules/node-pty entry (version, resolved, integrity; npm also dropped its license line). One change outside the node-pty subtree: npm wrote the root package's engines ("node": ">=20 <23") into the lock's root entry. It was already in package.json; the lock had drifted.
  • public/terminal-manager.js: the hidden accumulator keeps no CSI 6n, and the reveal replay strips any left, including a query split across chunks and one drained from the live write buffer.
  • output-buffer.js: main's reattach buffer drops CSI 6n on the way in, including a query split across several chunks (the last three code units are checked across entries).
  • Both remove the query in one pass, behind an includes check. Removing a query can join the bytes around it into a new one (ESC [ ESC [6n 6n becomes ESC [6n); that one is kept. The filter runs on every platform and removes any application's own CSI 6n too: answered live while the session is visible, unanswered while it is hidden or detached, where it used to be answered late with a stale position.
  • .ai/contexts/ipc-bridge.md: what the beta fixes and does not, the #922 stress results, and a "Cursor-position queries" section (measurements, the hidden/detached case, why the query is dropped rather than answered).
  • CHANGELOG entry under Fixed.

Findings

  1. Package layout (published tarballs)

    • prebuilds/win32-x64/conpty/ and prebuilds/win32-arm64/conpty/ still hold conpty.dll and OpenConsole.exe, at the path scripts/after-pack.js copies. conpty.cc still loads conpty\conpty.dll relative to conpty.node, and lib/utils.js still prefers build/Release over prebuilds/, so the afterPack hook is still needed and unchanged.
    • darwin-x64/arm64 prebuilds as before; the beta adds linux-x64/arm64 prebuilds.
    • winpty is gone from the beta; useConpty is deprecated and ignored. Switchboard never selects winpty.
    • Both versions are N-API. electron-builder still rebuilds node-pty from source in CI, which needs the same toolchain as today.
  2. API at our call sites (main.js spawnPty, pty-ops.js, remote-attach.js): only resize(cols, rows, pixelSize?) (optional, ignored on Windows) and the useConpty deprecation differ. SWITCHBOARD_NO_CONPTY_DLL keeps working.

  3. (terminal): close a pty once, and stop resizing or writing to a killed one (#405) #408's guard is still required. Killing a conpty-dll pty twice with raw term.kill() exits 3221226356 (0xc0000374) on both 1.1.0 and the beta; through killPty it exits 0 on both.

  4. #922, bounded stress attempt (scratch script, not in the suite: about a minute per run, and a pass proves nothing). One child process per run so a crash is an exit status; 20 iterations of 4 cmd.exe /c exit ptys plus one long-lived pty, 300 ms of resize calls straight into the binding while the short shells exit, and in kill mode one agent-level kill per short pty at a random moment. useConptyDll, node 24:

    Mode 1.1.0 1.2.0-beta.15
    kill 5 of 8 runs died, 0xc0000005 0 of 8 died
    resize 0 of 3 died 0 of 3 died

    One beta kill run finished its 20 iterations and then never exited (killed after 600 s); not explained. The race is non-deterministic: the exit thread's erase has to land between the main thread's lookup and its last use of the entry, and touching freed memory only crashes when the allocator has already reused or unmapped it. A kill holds the entry across ClosePseudoConsole and then writes to it, a wider window than one resize.

  5. Cursor-position query (Electron 41, isolated pty, useConptyDll)

    • No resize: no query in 10 s, on either version.
    • A resize in about the first 3 s: one query about 3.3 to 4 s after spawn; unanswered, repeated about every 520 ms and dropped after 3 (cmd.exe) or 4 (node shell); answered, sent once. A resize at 4 s or later: none. 1.1.0: none.
    • OpenConsole keeps waiting after its last repeat and consumes the first CPR that arrives, seconds later included. Every other CPR is passed to the application as typed input (ESC [ 24 ; 1 R read verbatim by a raw-mode node child); 1.1.0 passes it through the same way.
    • So the query is dropped from the hidden accumulator and from main's reattach buffer rather than answered at reveal: a late answer would give the cursor of a replay OpenConsole has moved past, and nothing tells the renderer whether OpenConsole is still waiting. Unanswered, OpenConsole keeps its own cursor, as on 1.1.0; its next query while the session is visible is answered live.

How it was tested

  • Tests written first and seen failing. Second commit: 5 new tests failed before the fix (accumulator keeps queries, reveal replays a drained one, reattach buffer keeps queries, split query, query-only chunk). Third commit: the three-chunk split on the main side and the single-pass expectation on both sides failed before the change.
  • Mutations on the final code, each restored after, 10, each failing at least one test: main tail read from the last entry only; main end-trim never popping an entry; split gate missing n (main, renderer); the repeated pass put back (main, renderer); split handling removed (main, renderer); strip removed in main; strip on replay removed. The includes fast paths change no output and are not mutation-tested.
  • A crafted 64 KB nested chunk: 0.46 ms on the main side now; the previous recursive version overflowed the stack on it.
  • npm ci in the worktree with the new lock: exit 0. electron-builder's rebuild of node-pty failed (no Visual Studio on this machine), and the postinstall fallback rebuilt better-sqlite3 only, so node-pty runs from the beta's prebuilds.
  • task check against that install, run by the pre-commit hook on the head commit: 3205 + 119 pass, 0 fail; eslint 0 errors. In a separate run just before, two timing tests of test/git-changes-runner.test.js (measureUntrackedLocal) failed; run alone, that file failed 1 time in 3. This change does not touch it. test/viewer-file-watch.test.js fails on this machine whenever TEMP is the 8.3 short path (JEAN-B~1, libuv fs-event.c assertion), at the previous head and in the main checkout too; with the long path it passes, so the hook ran with the long TEMP.
  • Electron 41.0.3 smoke with the beta's conpty.node: spawn, resizePty, writePty, two killPty (true, then false), resize after kill (false), onExit, exit 0. The app itself was not launched.

Not verified here

  • The electron-builder rebuild of the beta from source on windows-2022, macos-14, ubuntu x64 and arm64: CI only.
  • That the packaged Windows app finds conpty.dll under build/Release/conpty/: release candidate only.
  • The query rate and the replay behaviour in a real session with Claude as the shell: release candidate.

… race

node-pty 1.1.0 lets the ConPTY exit thread erase a handle-table entry while
PtyKill or PtyResize still use it, a heap corruption separate from the double
close of #405. 1.2.0-beta.15 carries upstream #922, a mutex around that table.
Pinned exactly because it is a pre-release. The beta still corrupts the heap on
a second kill, so pty-ops' once-only kill guard stays.

Closes #409
@devsuitup

Copy link
Copy Markdown
Owner Author

Adversarial review at 9cd7feb: changes requested.

Blocking:

  1. Hidden sessions never answer the beta's cursor-position query. A hidden single-view session's output goes to the hidden accumulator only (public/terminal-manager.js:800-808, :409), so xterm never sees CSI 6n and never replies; by the PR's own measurement OpenConsole repeats it about every 500 ms until answered. Each repeat is an IPC message, an appendToOutputBuffer (main.js:2309) and an accumulator append, per hidden live session (e.g. ten sessions restored hidden). On reveal, replayHiddenBuffer writes all stored queries at once and xterm sends N replies where OpenConsole expects one; the same replay happens from main's outputBuffer on renderer reattach (main.js:2374). Needs a measurement of the unanswered rate and a fix (or proof it does not repeat).
  2. Evidence for #922: the race is read in the source, never observed, and no test fails before the change. A native exit-thread race is not deterministic; at least a bounded stress attempt (kill/resize at shell exit, 1.1.0 vs beta) and the reason written down.

Non-blocking:
3. .ai/contexts/ipc-bridge.md describes only the answered case of the query.
4. CHANGELOG.md:18 claims more than shown ("can no longer crash"); the node-pty version is an internal detail per docs/changelog.md.

Nit: package-lock.json drops node-pty's license (lock from abbreviated metadata); harmless.

Checked: lockfile moves only the node-pty entry (+ root engines already in package.json), resolved/integrity match the registry; our call sites use no changed API (resize(cols, rows), kill(), onExit, useConptyDll); the beta still ships prebuilds/win32-{x64,arm64}/conpty/ and still prefers build/Release, so scripts/after-pack.js stays valid; #408's killedPtys guard unchanged and not bypassed; a plain CPR leaves composer text untouched.

node-pty 1.2.0-beta.15's OpenConsole asks for the cursor position (CSI 6n)
after an early resize and repeats it until answered. A hidden session's
output goes to the hidden accumulator and a detached one's to main's
reattach buffer, so the query waited there and xterm answered every copy
on reveal or reattach. OpenConsole consumes one reply and passes the rest
to the application as typed input. Both buffers now drop the query,
including one split across two chunks; it is not answered late, since a
replayed cursor would be stale.

The context doc records the query measurements and the #922 stress runs
(1.1.0 died in 5 of 8 kill runs, the beta in none). The changelog line
no longer names the library version.

Refs #409
@devsuitup

Copy link
Copy Markdown
Owner Author

Delta review 9cd7feb..fd1ee5e: the hidden-session and reattach replay of CSI 6n is fixed; no blocking code defect. All checks green on fd1ee5e; the windows-2022 build log shows [after-pack] copied bundled ConPTY to ...\node-pty\build\Release\conpty, so the hook fires against the beta.

Evidence: the #922 race has no failing CI run (not deterministic, ~1 min per stress run); the local stress table (kill mode: 1.1.0 crashed 5/8, beta 0/8) is the evidence offered. Merging records an exception to the red-before-fix gate. One beta stress run hung on exit for 600 s, unexplained.

To fix before merge:

  1. output-buffer.js:72-75 and public/terminal-manager.js:719-726: the nested re-strip removes bytes that are not a query (ESC [ ESC [6n 6n comes out empty, where xterm would show 6n) and is quadratic: a crafted 64 KB chunk blocks main for ~1.6 s. A real query cannot nest: one non-recursive pass.
  2. output-buffer.js:86-95: a query split over three chunks survives on the main side (the renderer handles it).
  3. ipc-bridge.md: say the filter applies on every platform and to any application's own CSI 6n, not only OpenConsole's.
    Nit: check includes before split on the hot path.

Checked: CSI ?6n and CSI 6;1n pass through; two-chunk splits at every byte position are removed with the buffer size kept consistent; loops terminate; visible path, write buffer and 30 fps flush untouched; LRU-evicted sessions replay the stripped main buffer; CHANGELOG wording follows docs/changelog.md.

…oss entries

The nested re-strip removed bytes that are not a query (ESC [ ESC [6n 6n
became empty, where xterm shows 6n) and was quadratic; on a crafted 64 KB
chunk the recursive main-side version overflowed the stack. One pass now,
behind an includes check. Main's reattach buffer also looks back across
entries, so a query split over three chunks no longer survives, and only a
chunk starting with [, 6 or n is checked for a split at all. The context
doc says the filter runs on every platform and for any application's own
query.

Refs #409
@devsuitup

Copy link
Copy Markdown
Owner Author

Delta review at a85a24d: approved. Main and renderer strips checked against every one of the 262,144 ways to cut a 20-char sample into chunks (queries split over 1-4 entries, ESC[6+n, lone ESC+[6n): 0 failures, buffer size consistent, no empty entry; cap interplay checked; all checks green. Accepted trade-off: crafted ESC [ ESC [6n 6n replays as one ESC [6n. Nit left as is: the doc's "overflowed the stack" for the old recursive pass was not reproduced by the reviewer (1.6 s block instead).

Evidence gate, maintainer decision: the #922 fix is upstream's native race fix, not deterministic, so no CI run can fail reliably before it; the local stress run (1.1.0 crashed 5/8 in kill mode, the beta 0/8) is the evidence, and the release candidate is the real check. The CSI 6n filter fixes behaviour the beta introduces in this Pr, so main had nothing red to show.

@devsuitup
devsuitup merged commit c3ac346 into main Oct 3, 2026
12 checks passed
@devsuitup
devsuitup deleted the deps/409-node-pty-beta branch October 3, 2026 13:59
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.

(terminal): evaluate node-pty with upstream #922 (exit-thread race in conpty.cc)

1 participant