Skip to content

Windows: ConoutConnection worker and conpty_console_list_agent inherit process.execArgv, so the host's --require preload runs inside them #974

Description

@liguobao

Summary

node-pty starts two helpers of its own on Windows and passes no execArgv to either:

  • lib/windowsConoutConnection.jsnew worker_threads_1.Worker(<…>/worker/conoutSocketWorker.js, { workerData: workerData })
  • lib/windowsPtyAgent.jschild_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.

Activity

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