Summary
node-pty starts two helpers of its own on Windows and passes no execArgv to either:
lib/windowsConoutConnection.js — new worker_threads_1.Worker(<…>/worker/conoutSocketWorker.js, { workerData: workerData })
lib/windowsPtyAgent.js — child_process_1.fork(<…>/conpty_console_list_agent, [_this._innerPid.toString()])
Node defaults execArgv for both to process.execArgv, so every --require / --import / --loader / --inspect flag the embedding application put on its own command line is replayed inside node-pty's helpers. A host preload therefore runs a second time in a completely different environment, where the host's globals do not exist.
For a preload that is sensitive to that difference this is fatal, and it happens on terminal creation: WindowsPtyAgent's constructor builds the ConoutConnection worker synchronously, so the second run of the preload is part of "open a terminal".
Impact
Reproducible crash in an Electron desktop app. The wrapper launches the app's Node child with --require parent-watch.cjs, which keeps the child alive only while its stdin is open:
if (process.stdin !== null) {
process.stdin.resume();
process.stdin.once('end', () => process.kill(process.pid, 'SIGTERM'));
}
Inside the ConPTY output worker process.stdin is not null, but its 'end' event fires immediately. The watcher concludes its parent is gone and SIGTERMs the process. A worker thread shares the process, so the whole application dies with exit code 1 — no stderr, no uncaught exception, no Node crash report, nothing in the Windows event log. Opening the terminal kills the app, every time.
The stack never sees a JavaScript error, so from the embedder's side this is completely silent and extremely hard to attribute.
Versions
Both shipped layouts are affected; the compiled bytes differ but the defect is identical.
- node-pty 1.1.0 —
new worker_threads_1.Worker(path_1.join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData })
- node-pty 1.2.0-beta.15 —
new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData })
Reproduction
The mechanism needs neither Electron nor node-pty — only the same call shape. Verified on Node v22.13.0, Windows 11 (build 26200).
watcher.cjs — the embedder preload:
if (process.stdin !== null) {
process.stdin.resume();
process.stdin.once('end', () => process.kill(process.pid, 'SIGTERM'));
}
host.cjs — stands in for WindowsPtyAgent:
const { Worker } = require('node:worker_threads');
const mode = process.argv[2];
new Worker(
"require('node:fs').appendFileSync(process.env.LOG, 'worker ran, execArgv=' + JSON.stringify(process.execArgv) + '\\n')",
{ eval: true, ...(mode === 'empty' ? { execArgv: [] } : {}) }, // node-pty currently behaves like 'inherit'
);
setTimeout(() => require('node:fs').writeFileSync(process.env.LOG + '.survived', 'survived\n'), 4000);
A driver spawns host.cjs with --require watcher.cjs and stdio: ['pipe', 'pipe', 'pipe'], keeping the host's own stdin open for the whole run, so the only stdin that can end is the worker's.
With inherit — what node-pty does today:
[+32ms] threadId=0 preload loaded (stdin=stream)
[+34ms] host started execArgv=["--require","...watcher.cjs"]
[+57ms] threadId=1 preload loaded (stdin=stream) <-- the preload ran again inside the worker
[+57ms] threadId=1 preload armed on stdin
worker body ran, execArgv=["--require","...watcher.cjs"]
[+59ms] threadId=1 preload fired: stdin ended -> SIGTERM
-> host exit code=1, signal=null, empty stderr
With execArgv: [] — the proposed fix:
[+31ms] host started execArgv=["--require","...watcher.cjs"]
worker body ran, execArgv=[]
[+55ms] worker exited code=0
[+4046ms] host survived
The forked agent inherits it too — the same probe shows the forked child's process.execArgv is ["--require", ".../noop-preload.cjs"] and the preload executes in it:
preload ran in pid=23736 threadId=0 execArgv=["--require","...noop-preload.cjs"] <-- host
parent: execArgv=["--require","...noop-preload.cjs"]
preload ran in pid=41868 threadId=0 execArgv=["--require","...noop-preload.cjs"] <-- forked child
forked child: execArgv=["--require","...noop-preload.cjs"] stdin=stream
Proposed fix
Give both helpers an explicit empty execArgv:
--- lib/windowsConoutConnection.js
+++ lib/windowsConoutConnection.js
- this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData });
+ this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData, execArgv: [] });
--- lib/windowsPtyAgent.js
+++ lib/windowsPtyAgent.js
- var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]);
+ var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()], { execArgv: [] });
Why execArgv: [] and not filtering --require
- Both helpers are internal plumbing — one pumps a named pipe, the other reads a console process list. They need none of the host's flags: no V8 options, no loaders, no inspector, no experimental switches.
- A deny-list would break again on the next flag Node adds, and
--require has several spellings (-r, --import, --experimental-loader, --loader).
- It is also a boundary: a host's preload/loader should not be able to reach into a library's private helper processes at all.
The same reasoning applies to every other place node-pty uses fork or Worker; the two above are the ones on the Windows terminal path.
Workaround
Embedders currently apply an exact-byte postinstall patch to those two lines, and a host that also gates its preload on its own entry file happens to avoid the crash. Neither is a substitute for the fix in-tree.
One caveat I could not settle
execArgv: [] closes the command-line path. If a host injects its preload through NODE_OPTIONS=--require … instead, the worker shares process.env and new Worker has no env option, so I have not been able to rule that path out — it may be worth its own check while you are in here.
Summary
node-pty starts two helpers of its own on Windows and passes no
execArgvto either:lib/windowsConoutConnection.js—new worker_threads_1.Worker(<…>/worker/conoutSocketWorker.js, { workerData: workerData })lib/windowsPtyAgent.js—child_process_1.fork(<…>/conpty_console_list_agent, [_this._innerPid.toString()])Node defaults
execArgvfor both toprocess.execArgv, so every--require/--import/--loader/--inspectflag the embedding application put on its own command line is replayed inside node-pty's helpers. A host preload therefore runs a second time in a completely different environment, where the host's globals do not exist.For a preload that is sensitive to that difference this is fatal, and it happens on terminal creation:
WindowsPtyAgent's constructor builds theConoutConnectionworker synchronously, so the second run of the preload is part of "open a terminal".Impact
Reproducible crash in an Electron desktop app. The wrapper launches the app's Node child with
--require parent-watch.cjs, which keeps the child alive only while its stdin is open:Inside the ConPTY output worker
process.stdinis not null, but its'end'event fires immediately. The watcher concludes its parent is gone and SIGTERMs the process. A worker thread shares the process, so the whole application dies with exit code 1 — no stderr, no uncaught exception, no Node crash report, nothing in the Windows event log. Opening the terminal kills the app, every time.The stack never sees a JavaScript error, so from the embedder's side this is completely silent and extremely hard to attribute.
Versions
Both shipped layouts are affected; the compiled bytes differ but the defect is identical.
new worker_threads_1.Worker(path_1.join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData })new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData })Reproduction
The mechanism needs neither Electron nor node-pty — only the same call shape. Verified on Node v22.13.0, Windows 11 (build 26200).
watcher.cjs— the embedder preload:host.cjs— stands in forWindowsPtyAgent:A driver spawns
host.cjswith--require watcher.cjsandstdio: ['pipe', 'pipe', 'pipe'], keeping the host's own stdin open for the whole run, so the only stdin that can end is the worker's.With
inherit— what node-pty does today:With
execArgv: []— the proposed fix:The forked agent inherits it too — the same probe shows the forked child's
process.execArgvis["--require", ".../noop-preload.cjs"]and the preload executes in it:Proposed fix
Give both helpers an explicit empty
execArgv:Why
execArgv: []and not filtering--require--requirehas several spellings (-r,--import,--experimental-loader,--loader).The same reasoning applies to every other place node-pty uses
forkorWorker; the two above are the ones on the Windows terminal path.Workaround
Embedders currently apply an exact-byte postinstall patch to those two lines, and a host that also gates its preload on its own entry file happens to avoid the crash. Neither is a substitute for the fix in-tree.
One caveat I could not settle
execArgv: []closes the command-line path. If a host injects its preload throughNODE_OPTIONS=--require …instead, the worker sharesprocess.envandnew Workerhas noenvoption, so I have not been able to rule that path out — it may be worth its own check while you are in here.