Repository navigation
Windows: a second kill() on the same pty closes the pseudo-console twice and crashes the process with 0xC0000374 #983
Description
Environment details
- OS: Windows 11 Pro x64
- OS version: 10.0.26200
- node-pty version: 1.2.0-beta.15 (
src/win/conpty.cconmainis identical, checked 2026-09-29); Node 26.7.0;useConptyDll: true(the bundled conpty.dll 1.25.260303002)
Issue description
Calling kill() twice on the same Windows IPty closes the pseudo-console twice and corrupts the process heap: the process dies with 0xC0000374 (STATUS_HEAP_CORRUPTION, exit code 3221226356). Two kill() calls in a row crash every time; a second kill() issued from the onExit handler crashes only when the exit event is delivered before the native exit watcher has run, which happens on a busy machine such as a CI runner.
Where
src/win/conpty.cc:
PtyKilllooks the pty's record up underg_ptyHandlesMutexand, whenever the record still exists, callsConptyClosePseudoConsole(handle->hpc), which frees the pseudo-console's structure. It does not mark the record closed:handle->hpckeeps its value.- The record is removed only by the exit-watcher thread in
SetupExitCallback, afterWaitForSingleObject(baton->hShell)returns; the JSonExitis delivered later (ConPTY: the pseudoconsole is never closed on a natural shell exit — the exit watcher erases the baton before onExit, leaking one conhost.exe per pty #965 describes this lifetime). - So every
PtyKillthat runs before the watcher thread has erased the record closes the pseudo-console again.WindowsTerminal.kill()andWindowsPtyAgent.kill()have no guard against a second call either.
With useConptyDll: false the same script survives on this Windows build, so the built-in ClosePseudoConsole seems to tolerate the second call; I have not looked into why.
Reproduction
const pty = require("node-pty");
const shell = pty.spawn("cmd.exe", [], { cols: 80, rows: 24, useConptyDll: true });
shell.onData(() => {});
setTimeout(() => {
shell.kill();
shell.kill(); // second close of the same pseudo-console
setTimeout(() => { console.log("survived"); process.exit(0); }, 3000);
}, 1000);Exit code 3221226356 (0xC0000374) in 3 of 3 runs with cmd.exe and 1 of 1 with powershell.exe; "survived" never prints.
The timing-dependent form, which is what an application hits: call kill() once, then call kill() again from onExit (for example to make sure the pseudo-console is closed after a natural exit, see #965). onExit fires when the conout pipe closes, about 60 ms after the kill on an idle machine, and the watcher thread has usually erased the record by then. With the process confined to 4 logical processors beside 8 busy processes, in 20 processes of 20 spawn–kill cycles each: 2 crashes in 380 cycles with cmd.exe and 8 in 279 with powershell.exe; with a single kill(): 0 in 800; unconfined: 0 in 400. Every crash followed an onExit whose exitCode was undefined, i.e. delivered before the native exit callback had run.
Expected
A second kill() never closes the pseudo-console twice. For example PtyKill could set handle->hpc = nullptr after closing and skip a null hpc, also in PtyResize and PtyClear, which currently use the freed pointer when they run between a kill and the watcher thread; or PtyKill could erase the record itself.
Related
- ConPTY: the pseudoconsole is never closed on a natural shell exit — the exit watcher erases the baton before onExit, leaking one conhost.exe per pty #965: the exit watcher erases the record before
onExit, so a natural exit never closes the pseudo-console; callingkill()fromonExitto work around that is what makes this crash reachable. - Windows: ConoutConnection worker thread prevents Node.js from exiting after kill() #887: the conout worker keeps the process alive after
kill().
Activity
hyzyn commentedon Oct 13, 2026
Independent confirmation from a real consumer — and on the
useConptyDll: falsepath (the default).This report says the built-in
ClosePseudoConsole"seems to tolerate" the second call whenuseConptyDllis false. In our case it does not: it kills the whole host process with the same0xC0000374.Environment: Windows 11 ARM64 (Parallels VM), Node v22.23.3, node-pty 1.2.0-beta.15 pulled in transitively,
useConptyDllunset (false),conpty.nodefromprebuilds/win32-arm64.How the second call happens in an application (not a synthetic double call): a terminal-manager plugin (
@hyzyn/dsh-tty) spawnscmd.exe, and on "close session" it explicitly kills the top-level shell first (killSessionNow()→ its ownterminal.kill()pre-kill), then lets the host's teardown run (stopShellWindows()→this.terminal.kill()when it cannot resolve the shell's start identity — which is the normal case there, because the host samplesterminal.pidbefore the conout connection is established and gets0). Both end up atWindowsTerminal.kill()→WindowsPtyAgent.kill()→pty.kill(id, false), roughly 40 ms apart. Note that the plugin-side call is often deferred by_deferNoArgs(_isReadyis still false right after spawn), so the two calls land in either order depending on timing.Instrumentation: we wrapped the
conpty.nodeexports (startProcess/connect/kill) plusWindowsPtyAgent.prototype.kill,WindowsTerminal.prototype.killandConoutConnection.prototype.dispose, logging withwriteSyncso the log survives the crash. Every session logs exactly twoNATIVE-KILL id=Nlines for the same pty id; the second one kills the process.A/B — same harness, same ordering, 150 spawn/kill cycles each; the only difference is a one-shot gate in front of
WindowsPtyAgent.kill():0xC0000374)The unguarded run died at cycle 75, immediately after the 94th double close. So on this build the second
ClosePseudoConsole(kernel32's,useConptyDll: false) corrupts the heap exactly like the bundled-conpty.dll path in the report above.On the suggested fix: in this configuration both calls reach
PtyKillwhile the record is still present, sohandle->hpc = nullptrafter the close (plus the null checks inPtyResize/PtyClear, which currently use the freed pointer in that same window) would have prevented it, as would erasing the record insidePtyKill. We worked around it on the consumer side with a one-shot gate onWindowsPtyAgent.kill()— the JS-levelkill()is not idempotent either — but a library-side guard would be the real fix.The instrumented repro scripts (probe preload + a spawn/kill driver, both standalone) are small; happy to attach them if they would help.