Skip to content

Windows: a second kill() on the same pty closes the pseudo-console twice and crashes the process with 0xC0000374 #983

Open

Description

Environment details

  • OS: Windows 11 Pro x64
  • OS version: 10.0.26200
  • node-pty version: 1.2.0-beta.15 (src/win/conpty.cc on main is 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:

  • PtyKill looks the pty's record up under g_ptyHandlesMutex and, whenever the record still exists, calls ConptyClosePseudoConsole(handle->hpc), which frees the pseudo-console's structure. It does not mark the record closed: handle->hpc keeps its value.
  • The record is removed only by the exit-watcher thread in SetupExitCallback, after WaitForSingleObject(baton->hShell) returns; the JS onExit is 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 PtyKill that runs before the watcher thread has erased the record closes the pseudo-console again. WindowsTerminal.kill() and WindowsPtyAgent.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

Activity

  1. hyzyn commented on Oct 13, 2026

    @hyzyn

    Independent confirmation from a real consumer — and on the useConptyDll: false path (the default).

    This report says the built-in ClosePseudoConsole "seems to tolerate" the second call when useConptyDll is false. In our case it does not: it kills the whole host process with the same 0xC0000374.

    Environment: Windows 11 ARM64 (Parallels VM), Node v22.23.3, node-pty 1.2.0-beta.15 pulled in transitively, useConptyDll unset (false), conpty.node from prebuilds/win32-arm64.

    How the second call happens in an application (not a synthetic double call): a terminal-manager plugin (@hyzyn/dsh-tty) spawns cmd.exe, and on "close session" it explicitly kills the top-level shell first (killSessionNow() → its own terminal.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 samples terminal.pid before the conout connection is established and gets 0). Both end up at WindowsTerminal.kill() → WindowsPtyAgent.kill() → pty.kill(id, false), roughly 40 ms apart. Note that the plugin-side call is often deferred by _deferNoArgs (_isReady is still false right after spawn), so the two calls land in either order depending on timing.

    Instrumentation: we wrapped the conpty.node exports (startProcess / connect / kill) plus WindowsPtyAgent.prototype.kill, WindowsTerminal.prototype.kill and ConoutConnection.prototype.dispose, logging with writeSync so the log survives the crash. Every session logs exactly two NATIVE-KILL id=N lines 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():

    variant cycles native kills double kills exit code
    kill-then-teardown, no gate 150 188 94 -1073740940 (0xC0000374)
    kill-then-teardown, one-shot gate 150 150 0 0
    teardown only (single kill) 150 150 0 0

    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 PtyKill while the record is still present, so handle->hpc = nullptr after the close (plus the null checks in PtyResize / PtyClear, which currently use the freed pointer in that same window) would have prevented it, as would erasing the record inside PtyKill. We worked around it on the consumer side with a one-shot gate on WindowsPtyAgent.kill() — the JS-level kill() 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions