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
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 58/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- cpp
- Área
- operating-systems
Línea de trabajo
Comience en src/win/conpty.cc leyendo pty_baton, SetupExitCallback y PtyKill para rastrear la destrucción del baton y la limpieza de la pseudoconsola. Ejecute la reproducción en Windows con procesos de cmd.exe que finalicen de forma natural y, después, verifique que no se acumulen instancias de conhost.exe mientras la ruta «kill-before-exit» siga funcionando correctamente.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Summary
On Windows/ConPTY, when a shell exits on its own (rather than via kill()), ClosePseudoConsole is never called. The pty_baton is erased by the exit-watcher thread before the JS onExit is delivered, struct pty_baton has no destructor, and PtyKill — the only JS-reachable route to ClosePseudoConsole — silently no-ops once the baton is gone.
The result is one orphaned conhost.exe --headless (~8 MB working set) per pty, held for the lifetime of the host process. Because the erase happens before any JS callback runs, no client of node-pty can work around this: there is no point at which a consumer could call kill() and still find a live baton.
Present on main today, and on 1.2.0-beta.10 / 1.2.0-beta.14.
Where
src/win/conpty.cc on main:
// struct has a ctor, no dtor — nothing closes hpc when the unique_ptr dies
:43-52 struct pty_baton {
int id; HANDLE hIn; HANDLE hOut; HPCON hpc; HANDLE hShell = nullptr;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : ... {};
};
:54 static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
// SetupExitCallback's watcher thread
:95 WaitForSingleObject(baton->hShell, INFINITE);
:96-104 {
std::lock_guard<std::mutex> lock(g_ptyHandlesMutex);
GetExitCodeProcess(...); CloseHandle(baton->hShell);
std::erase_if(ptyHandles, ...); // <-- baton destroyed here; hpc NOT closed
}
:106 auto status = tsfn.BlockingCall(exit_event, callback); // <-- JS onExit, AFTER the erase
// PtyKill — the only exported route to ClosePseudoConsole
:569-571 std::lock_guard<std::mutex> lock(g_ptyHandlesMutex);
pty_baton* handle = get_pty_baton(lock, id);
:572 if (handle != nullptr) {
:582 pfnClosePseudoConsole(handle->hpc); // <-- skipped when the baton is gone
:599 return env.Undefined(); // <-- silent: no throw, no diagnostic
init() exports only startProcess | connect | resize | clear | kill, and pfnClosePseudoConsole is called from exactly one place, so PtyKill really is the only way for a consumer to reach it.
Worth noting the defensive CloseHandle(handle->hIn / handle->hOut) recently added inside PtyKill is unreachable on this same path, for the same reason.
On 1.2.0-beta.10, the erase is a side effect inside assert()
The shipped beta line has this shape, which is worth calling out because it inverts the usual expectation:
:105 CloseHandle(baton->hShell);
:106 assert(remove_pty_baton(baton->id));
:108 auto status = tsfn.BlockingCall(exit_event, callback);
Under NDEBUG, assert(expr) expands to ((void)0) and remove_pty_baton never runs — the baton survives and PtyKill closes the HPCON correctly. So on that version the leak exists only when asserts are enabled.
They are enabled in the published binaries. strings -a -el on @lydell/[email protected]'s prebuilds/win32-x64/conpty.node (which is built from [email protected]) yields, in UTF-16LE:
remove_pty_baton(baton->id)
Assertion failed: %Ts, file %Ts, line %d
D:\a\_work\1\s\src\win\conpty.cc
The literal expression text is only emitted for a compiled-in MSVC assert, and binding.gyp defines no NDEBUG.
Hoisting it out of the assert to an unconditional std::erase_if (done at 1.2.0-beta.14, :101, still before BlockingCall at :106) fixes the side-effect-in-assert anti-pattern, but it makes the erase reliable and therefore makes the HPCON leak deterministic on every build rather than only on assert-enabled ones.
Field evidence
From a downstream report against a CLI that creates one pty per shell command (QwenLM/qwen-code#11303 — that project pins @lydell/node-pty 1.2.0-beta.10):
- 347 orphaned
conhost.exe --headlessunder a single host process after ~12 h, ~2.8 GB working set. - Growth is 1:1 with pty creations: +7 orphans for exactly 7 shell commands, across two independent samples.
- The processes die only when the host process itself exits.
The same report shows the parent's thread count growing in lockstep (353 threads against 347 orphans) — that is the per-pty ConoutConnection worker, which ConoutConnection.dispose() frees and which is likewise reachable only from kill(). Downstream can fix that half by calling dispose() directly; the HPCON half has no such workaround.
Reproduction
I do not have a Windows machine, so I have not run this myself — the analysis above is from the source at the affected versions plus the shipped prebuild's strings, and the counts are the downstream reporter's. A maintainer should be able to confirm quickly:
- Spawn a pty running a short command that exits on its own (
cmd.exe /c echo hi). - Let it exit naturally; do not call
ptyProcess.kill(). - Repeat N times.
- Count
conhost.exe --headlesschildren of the host process — it should grow by N and never shrink.
Contrast with the same loop where kill() is called before the shell exits, which closes the HPCON correctly.
Suggested fix
Close the pseudoconsole where the baton is destroyed, so it does not depend on a consumer calling kill() in a window that no longer exists. Either:
- give
pty_batona destructor that callsClosePseudoConsole(hpc)(and closeshIn/hOutif still open), sostd::erase_if/unique_ptrteardown does the right thing everywhere; or - call
ClosePseudoConsole(baton->hpc)explicitly in the watcher, immediately before theerase_if.
The destructor is the more robust of the two — it also covers the remove_pty_baton call sites and any future erase — but it needs LoadConptyDll's function pointer to be resolvable from that context, which is why the explicit call in the watcher may be the smaller change.
Either way PtyKill's existing close stays correct for the kill-before-exit path, and becomes a no-op-after-close rather than the only close.
Related
- #333 — the same family, for the shell process rather than the pseudoconsole host.
- #947, #952 — other leaks/races on the ConPTY
kill()path; independent of this one, which is about the path wherekill()is never called at all.
- Lenguaje dominante
- TypeScript
- Estrellas
- 2k
- Forks
- 341
- Merge medio
- 1 d 1 h
- PR fusionados (30 d)
- 7
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de microsoft/node-pty
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
ConPTY/TSFN exit callback aborts the process during environment teardown — fixable with NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS (same root cause as #904)Posiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
microsoft/node-pty#951 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
Windows: conin socket has no 'error' listener, so a failed pty write is an uncaught exceptionAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 1 día
Todos los issues de microsoft/node-pty
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
cameri/nostream#811 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
bug p3 triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
Los mantenedores suelen responder en 1 día
-
bug javascript P2-medium python release:v3.1
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
adrirubio/claude-deck#546 ·
Los mantenedores suelen responder en 1 día
-
area: desktop area: website priority: P2 type: feature
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
appandflow/stim#3411 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
needs triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
rjsf-team/react-jsonschema-form#5485 ·
Los mantenedores suelen responder en 2 días