Files
orca/config/patches/node-pty@1.1.0.patch
T
Neil 756d623955 perf(terminal): bound the in-process macOS process table's blocking calls and ps fallbacks (#31783) (#36282)
* fix(terminal): bound the in-process macOS process table's blocking calls and ps fallbacks (#31783)

Follow-up to #35987 from its post-merge reviews.

- cwd and executable path: node-pty's readProcessCwd/readProcessPath now run
  PROC_PIDVNODEPATHINFO and proc_pidpath on a libuv worker (the synchronous
  exports are gone, so an older pty.node falls back to ps/lsof). JS caps them at
  two outstanding lookups and 250ms; a wedged mount or a full cap answers from
  lsof -b, which never stats, so a dead NFS/SMB/FUSE mount cannot freeze the
  daemon's event loop or take the whole pool.
- Unreadable argv prints "(comm)", as ps does, instead of a proc_pidpath lookup
  under Orca's credentials on the table worker.
- The table exports each row's euid. Argv recovery runs setuid ps only for
  another user's processes, never for our own mid-exec or exiting ones (or
  SIDL/exiting rows), which ps cannot read either: a pane looping short
  commands forked ps on ~15% of full reads, now none.
- Recovery is coalesced per process across concurrent readers and a failed
  recovery waits 5s before costing another ps. The cache keys on comm and start
  time.
- Full-table and shell-foreground captures, which feed strict evidence with
  the capture's age, take only argv ps read after their request; display
  readers keep the 30s cache.
- While the reader is down (stalled-pool breaker open, or a rejecting addon,
  which now backs off 1s..30s), runPsInProcess runs /bin/ps itself through one
  shared budget: identical invocations share a run, a burst of 4 forks then one
  a second, and a run spawned before a request never answers it. Descendant
  teardown and foreground-group signalling keep their own full-timeout ps
  (callerRunsPs). 13 pollers at 750ms: 1,040 ps/min before, 63 after.

No daemon protocol bump: the wire format is unchanged, and each daemon loads
JS and pty.node from the same runtime copy.

* fix(terminal): make the libproc reader survive worker teardown, fail closed and share one fair fork budget (#31783)

Review follow-ups to the previous commit.

- Native: every libproc AsyncWorker derives from OrcaPromiseWorker, which skips
  JS completion once an env cleanup hook marks the env closing and swallows a
  Napi::Error thrown mid-teardown. Terminating a worker_thread with cwd or table
  reads in flight aborted the process; 200 terminates each now survive.
- (comm) in a terminal's foreground group (argv neither reader recovered, e.g.
  a root `su`) judges 'unknown', never 'agent', so the launch line is not typed
  into it.
- Fresh argv only for getFreshSnapshot captures; the 500ms TTL readers keep the
  30s cache. Argv recovery runs through its own fork budget (2, then one per
  2s). Root `su` pane, 13 pollers at 750ms: TTL 79 -> 2 ps/min, fresh 158 -> 31.
- The fork budget serves invocations in FIFO turns, grouping each one's waiters
  onto one run. 13 streams: served 80,42,1,1,2,0x8 -> 10-12 each.
- Forced subprocess captures (a held naming pass) go through the fallback
  budget: 5 readers, 400 direct ps/min -> 63, 395 of 400 captures served.
- A failed recovery is retried 5s after ps gave up, not after it started.
- Tests pin Orca's euid instead of reading the runner's and assuming root is 0.
- process-cwd keeps one lsof per pid until it exits, so a dead mount no longer
  leaks one per poll; shell readiness reads the exe path through runProcess,
  which gives up on an lsof that ignores SIGTERM.
- A #35987 pty.node (table reader, no async path reads) keeps the in-process
  table; cwd/path use lsof, with a one-time warning.
- captureProcessTable moves to process-table-ps-capture.ts.

* fix(terminal): make withheld argv unverifiable and reuse recovered argv for at most 2s (#31783)

Round-2 review follow-ups.

- Worker teardown: drop OrcaPromiseWorker and its env flag (Node drains the
  pool before cleanup hooks, so it never fired). node-pty's mac pty target now
  builds with NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS, which covers both the
  call-in and completion paths. The smoke test now calls the reads from a
  timer while terminating: the previous build aborted 2 of 3 runs, this one
  survives 3/3 x 100. pty.cc is back to the first follow-up's exact content.
- A foreground process whose argv ps withheld (`(comm)`: another user's,
  exiting, or a recovery the budget skipped) makes the batch resolver, and so
  host-stamped evidence, `argv_unreadable`/unverifiable rather than live with no
  agent; agent-foreground resolution reports it unavailable.
- judgeTerminalForeground fails closed only for an unreadable shell (`(bash)`
  from a root `su`), not our own exiting `(git)`.
- Recovered argv is reused for at most 2s per identity (pid, start, comm) for
  every reader (the next commit ages evidence by it), and
  fresh reads no longer queue behind the recovery budget. Root `su` pane, 13
  fresh pollers: p50 2,989ms -> 53ms, 31 -> 26 ps/min (TTL reader 1 -> 26).
- The fork budgets serve interactive and fresh readers before display polls
  (cheap and TTL snapshot captures), FIFO within each. 20 display streams plus
  a 3s launch-paste read every 5s: 0/12 -> 12/12 served, polls 4-7 each.
- On a selected terminal, the foreground group's argv is recovered even with no
  process of ours left (`exec su`); the root login is not.
- Budgeted captures keep the 32 MiB output ceiling.

* fix(terminal): age strict evidence by its oldest recovered argv (#31783)

Round-3 review follow-ups.

- Recovered argv carried no read time, so a capture reusing it (cached, or a
  recovery still in flight) stamped its own capture time over argv up to 2s
  older, and the remote write guard could take a same-comm re-exec for the
  agent. Rows now carry when ps read their recovered argv; runPsInProcess
  reports the oldest through onArgvReadAt, and the snapshot reader stamps
  capturedAtMs at that input, so capturedAgeMs is the age of the oldest input
  and consumers' own freshness checks (the write guard's 500ms) decide. A
  recovery begun more than 2s before a reader asked no longer answers it, and
  an older recovery finishing late cannot replace a newer one.
- resolveSpawnFileForegroundFromRows reports a withheld `(node)` foreground
  unavailable.
- One budget queue entry per invocation: an interactive request promotes a
  queued poll's, a poll joins an interactive one.
- parseShellForegroundRows gets its JSDoc back.

* fix(terminal): keep a cached name from authorizing a refused write; integer, under-2s argv ages (#31783)

Round-4 review follow-ups.

- When the relay's evidence declines a write (stale, unverifiable, another
  agent), its foreground name may still prove the shell but no longer proves
  the agent: that name can be the tracker's 1s display cache, e.g. `claude`
  cached just before Claude exited to a root shell under su. Relays that send
  no evidence keep the name as before.
- Snapshot ages round up to whole milliseconds; the wire rejects fractions
  from the monotonic-to-wall conversion.
- Recovered argv is reused for at most 1.5s, so reuse plus the 500ms snapshot
  cache stays under the 2s freshness gate; reuse ages from when that ps
  spawned, not from the wait for its fork token, and only an answer is
  remembered (a timed-out or withheld run proves nothing). Fork budget
  unchanged. Root su pane, 13 pollers: 30 ps/min; fresh p50 51ms; strict
  evidence age p50 752ms, max 1,508ms.

* fix(terminal): keep the relay's agent name behind structurally unverifiable evidence (#31783)

The previous commit refused the name fallback whenever the relay sent any
evidence, so `unverifiable` evidence for structural reasons also blocked
writes: npm Codex (node codex.js spawning native codex) and Gemini (relaunching
itself) put two agent rows in the foreground group (ambiguous_foreground_group),
and remote tmux reads multiplexer_boundary. On SSH POSIX hosts that dropped
launch prompts and failed worker briefs, conversation send/cancel and
automations with agent_not_in_foreground.

The name now cannot authorize only when the evidence saw the group (`live`:
stale, or no agent in it, as in the cached-`claude`-over-root-shell repro) or
withheld a foreground argv (`argv_unreadable`, possibly a root `su` shell).
Other unverifiable reasons keep the name fallback, as before this branch.

* fix(terminal): let a relay name complete only fresh agent-positive evidence (#31783)

The SSH name fallback still let a stale or cached name authorize a write under
any unverifiable reason but argv_unreadable: ps EAGAIN (process_table_unreadable)
after the tracker cached `claude` over a root bash, anchor_missing,
tty_boundary (`sudo -i` with use_pty, a root shell on a new pty), or a 1.5s-old
ambiguous capture.

- The name now completes evidence only when it is fresh (within the guard's
  500ms bound), its reason is one that saw an agent row
  (ambiguous_foreground_group, multiplexer_boundary,
  candidate_start_time_missing), and the name is the launched agent. Every other
  reason, including ones this build does not know, reads unknown. Relays that
  send no evidence keep the name, for mixed versions.
- The relay's evidence names one agent that holds two rows of the group (npm
  Codex: node codex.js spawning native codex; Gemini relaunching itself), so a
  new relay proves them live even when its name is `node`. Two different agents
  stay ambiguous. No wire change.

* fix(terminal): judge a relay name by the host's capture age; keep old relays' npm Codex (#31783)

- Name completion compared the capture's start with the read's start, so a
  slow response made old evidence look fresh: a capture 1,500ms old arriving
  after 1,100ms passed. It now uses the host-reported capturedAgeMs against
  the 500ms bound, which latency cannot shrink. Live evidence keeps its
  capture-start rule.
- Relays before same-agent dedupe report npm Codex (node wrapper plus native
  binary) as ambiguous_foreground_group and name it `node`. For that reason
  only, `node` now completes the evidence when the launched agent is
  npm-installed per the node entrypoint tables; other agents and reasons stay
  fail-closed.

* fix(terminal): bound relay-name completion by twice the snapshot TTL (#31783)

The 500ms bound refused ~9.5% of tmux name completions on a host with 13
pollers (cached snapshot up to one 500ms TTL old plus ~30ms capture, max
528ms), and a refusal drops the launch prompt or throws with no retry. The
bound is now 2 x PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS (1s), still on the
host-reported age: 0 of 767 reads exceed it, the delayed 1,500ms repro still
refuses, and the cached-name hazard (the tracker's ~1s display cache) is no
wider.

* fix(terminal): complete relay evidence with a name read now, not the display cache (#31783)

With the 1s bound, a capture at 0-528ms, `codex` cached at 100ms, Codex
exiting at 101ms and the guard at 999ms still let the cached name authorize a
write over the bash that replaced it.

- Name completion (structural reasons, host age within 2x the snapshot TTL)
  now asks confirmForegroundProcess, which reads the foreground now (a fresh
  scan, or node-pty's raw process name) rather than the tracker's 1s display
  cache; a null answer reads unknown. No new wire surface: the local daemon answers
  confirmForegroundProcess, paired runtimes evaluate guards locally, and the
  production controller returns null (read as unknown, failing safe) when a
  provider lacks it. Controllers without the method, and relays sending no
  evidence, keep the cached name as the legacy path.
- Live evidence also accepts a host-reported age within 2x the TTL, so a real
  agent behind a 500-528ms cached snapshot on a fast link is no longer
  refused; a capture begun after the read was asked still counts, and
  5,000ms-old evidence still refuses.

* fix(terminal): confirm live evidence older than one snapshot TTL with a fresh read (#31783)

Accepting live evidence up to 2x the TTL reopened the window the previous
commit closed for name completion: a capture at 0-528ms, the agent exiting at
~529ms, and at 999ms `live claude` still read as agent over a bash.

Live evidence begun no more than one TTL before the read was asked (so any
host age within one TTL, however long ps took) still answers alone. Between
that and 2x the TTL, confirmForegroundProcess reads the foreground now: the
launched agent proves it, a shell reads shell, anything else unknown.
Controllers without that read fall through and refuse, as before.

Reachability: the remote (host.remote) write-guard path has no production
caller since the SSH provider's removal (#27499); the registry dispatches only
local providers and paired runtimes evaluate guards locally. This is defence
in depth for any future remote provider.
2026-10-30 01:05:34 -07:00

2130 lines
84 KiB
Diff

diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b4110a7c781d505ae581436cec68a10bf600734a 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
+ ],
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
+ 'cflags_cc!': [
+ '-std=gnu++20'
+ ],
+ 'cflags_cc': [
+ '-std=gnu++2a'
],
'conditions': [
['OS=="win"', {
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -42,32 +47,6 @@
'-lshlwapi'
],
},
- {
- 'target_name': 'conpty_console_list',
- 'sources' : [
- 'src/win/conpty_console_list.cc'
- ],
- },
- {
- 'target_name': 'pty',
- 'include_dirs' : [
- '<!(node -p "require(\'node-addon-api\').include_dir")',
- 'deps/winpty/src/include',
- ],
- # Disabled due to winpty
- 'msvs_disabled_warnings': [ 4506, 4530 ],
- 'dependencies' : [
- 'deps/winpty/src/winpty.gyp:winpty-agent',
- 'deps/winpty/src/winpty.gyp:winpty',
- ],
- 'sources' : [
- 'src/win/winpty.cc',
- 'src/win/path_util.cc'
- ],
- 'libraries': [
- '-lshlwapi'
- ],
- }
]
}, { # OS!="win"
'targets': [
@@ -88,6 +67,21 @@
'libraries!': [
'-lutil'
]
+ }],
+ # Orca: libproc reads complete on the pool; one landing while a
+ # worker_thread's env tears down must not abort the process.
+ ['OS=="mac"', {
+ 'defines': ['NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS']
+ }],
+ # Orca: pair with the .symver pins in pty.cc. Force the real
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
+ # --as-needed drops them because the pinned symbols resolve from
+ # libc's compat aliases at build time) so openpty/forkpty/
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
+ ['OS=="linux"', {
+ 'ldflags': [
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
+ ]
}]
]
}
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
+++ b/deps/winpty/src/winpty.gyp
@@ -10,7 +10,7 @@
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
'variables': {
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
},
'target_defaults' : {
'defines' : [
@@ -22,7 +22,7 @@
'include_dirs': [
# Add the 'src/gen' directory to the include path and force gyp to
# run the script (re)generating the version header.
- '<!(cmd /c "cd shared && UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
+ '<!(cmd /c "cd shared && .\\UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
]
},
'targets' : [
@@ -40,9 +40,6 @@
'-lshell32',
'-luser32',
],
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
@@ -142,9 +139,6 @@
'-ladvapi32',
'-luser32',
],
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/terminal.js b/lib/terminal.js
index e2f9bc9131077b53ebc32d207207ad82804ff185..6c63bfaaf75128d88f9a2efece13476348780cfd 100644
--- a/lib/terminal.js
+++ b/lib/terminal.js
@@ -172,6 +172,21 @@ var Terminal = /** @class */ (function () {
this.end = function () { };
this._writable = false;
this._readable = false;
+ // Orca: libuv closes the master fd on EIO/EOF, and the kernel may hand
+ // that number straight to the next open(2). Retire it in the same block
+ // that gives up the handle so no later ioctl can address a reused fd.
+ // Inert on Windows, where `_fd` is written once and never read back.
+ // Upstream named this mechanism in microsoft/node-pty#220 ("fd number got
+ // reattached to something else"), closed 2025-12-19 as completed after
+ // only improving the error message; #827 is still open. Windows guards in
+ // windowsPtyAgent.ts, Unix does not. Orca tracking: #18109.
+ this._fd = -1;
+ // Orca: the write stream holds its own copy of that number, so retiring
+ // `_fd` alone leaves the queued and in-flight writes addressing it.
+ // Undefined on Windows and on `UnixTerminal.open()` handles.
+ if (this._writeStream) {
+ this._writeStream.dispose();
+ }
};
Terminal.prototype._parseEnv = function (env) {
var keys = Object.keys(env || {});
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..1779098c54ff4be8c0d4dc9a93e96e71ededb01f 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
var pty = native.module;
var helperPath = native.dir + '/spawn-helper';
helperPath = path.resolve(__dirname, helperPath);
-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+if (!helperPath.includes('app.asar.unpacked')) {
+ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
+}
+if (!helperPath.includes('node_modules.asar.unpacked')) {
+ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+}
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
@@ -234,6 +238,11 @@ var UnixTerminal = /** @class */ (function (_super) {
* Gets the name of the process.
*/
get: function () {
+ // Orca: tcgetpgrp on a retired fd would name whatever process now
+ // owns that descriptor, so a closed master reports the spawn file.
+ if (this._fd < 0) {
+ return this._file;
+ }
if (process.platform === 'darwin') {
var title = pty.process(this._fd);
return (title !== 'kernel_task') ? title : this._file;
@@ -250,6 +259,11 @@ var UnixTerminal = /** @class */ (function (_super) {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
+ // Orca: a retired master is unreachable rather than EBADF-or-worse; cols
+ // and rows stay at the last size actually applied instead of a claim.
+ if (this._fd < 0) {
+ return;
+ }
pty.resize(this._fd, cols, rows);
this._cols = cols;
this._rows = rows;
@@ -273,6 +287,13 @@ var UnixTerminal = /** @class */ (function (_super) {
return UnixTerminal;
}(terminal_1.Terminal));
exports.UnixTerminal = UnixTerminal;
+/**
+ * Orca: upstream retries EAGAIN with `setImmediate`, which re-attempts within microseconds
+ * and pins a core on the daemon thread when a reader stops draining. 1ms keeps ~97% of that
+ * CPU saving; longer delays cost 2-3x delivery time to a reader that drains in bursts (an
+ * agent). Re-measure both before changing.
+ */
+var EAGAIN_RETRY_DELAY_MS = 1;
/**
* A custom write stream that writes directly to a file descriptor with proper
* handling of backpressure and errors. This avoids some event loop exhaustion
@@ -285,10 +306,17 @@ var CustomWriteStream = /** @class */ (function () {
this._writeQueue = [];
}
CustomWriteStream.prototype.dispose = function () {
- clearImmediate(this._writeImmediate);
+ clearTimeout(this._writeImmediate);
this._writeImmediate = undefined;
+ // Orca: retire this stream's own copy of the master fd and drop what has
+ // not shipped, so nothing queued here reaches a reused descriptor.
+ this._fd = -1;
+ this._writeQueue.length = 0;
};
CustomWriteStream.prototype.write = function (data) {
+ if (this._fd < 0) {
+ return;
+ }
// Writes are put in a queue and processed asynchronously in order to handle
// backpressure from the kernel buffer.
var buffer = typeof data === 'string'
@@ -304,7 +332,8 @@ var CustomWriteStream = /** @class */ (function () {
CustomWriteStream.prototype._processWriteQueue = function () {
var _this = this;
this._writeImmediate = undefined;
- if (this._writeQueue.length === 0) {
+ // Orca: an in-flight fs.write can re-enter here after dispose().
+ if (this._fd < 0 || this._writeQueue.length === 0) {
return;
}
var task = this._writeQueue[0];
@@ -314,9 +343,9 @@ var CustomWriteStream = /** @class */ (function () {
fs.write(this._fd, task.buffer, task.offset, function (err, written) {
if (err) {
if ('code' in err && err.code === 'EAGAIN') {
- // `setImmediate` is used to yield to the event loop and re-attempt
- // the write later.
- _this._writeImmediate = setImmediate(function () { return _this._processWriteQueue(); });
+ // Paced, not `setImmediate`: a stalled reader keeps this branch EAGAIN-ing, and an
+ // immediate re-attempt turns the retry into a busy-loop on the daemon thread.
+ _this._writeImmediate = setTimeout(function () { return _this._processWriteQueue(); }, EAGAIN_RETRY_DELAY_MS);
}
else {
// Stop processing immediately on unexpected error and log
diff --git a/lib/utils.js b/lib/utils.js
index af7918b6902a105a7c49813bc0bb4d687d87a7d4..1fca29d71f11ece98b8224b81ab78a6bc5b41a6d 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -15,8 +15,8 @@ function assign(target) {
}
exports.assign = assign;
function loadNativeModule(name) {
- // Check build, debug, and then prebuilds.
- var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
+ // Check Orca's per-ABI build (published into package dirs shared across worktrees), build, debug, and then prebuilds.
+ var dirs = ["build/orca-napi-" + process.platform + "-" + process.arch, 'build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
// Check relative to the parent dir for unbundled and then the current dir for bundled
var relative = ['..', '.'];
var lastError;
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
+let consoleProcessList: number[];
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+} catch {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..381d59e11bf158d269335c8231b407fc9c93ca19 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,8 +23,11 @@
#include <errno.h>
#include <string.h>
#include <stdlib.h>
+#include <stdio.h>
#include <unistd.h>
+#include <string>
#include <thread>
+#include <vector>
#include <sys/types.h>
#include <sys/stat.h>
@@ -47,6 +50,39 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
+ * those aliases are actually loaded on the target. */
+#if defined(__linux__)
+# if defined(__x86_64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
+# elif defined(__aarch64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
+# endif
+# ifdef ORCA_GLIBC_COMPAT_VERSION
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
+/* glibc 2.42 versioned cfset*speed to take literal rates (B38400 == 38400U);
+ * the pinned compat versions accept only the legacy CBAUD code (named
+ * __B38400 in 2.42+ headers) and return EINVAL otherwise (#19420). */
+__asm__(".symver cfsetispeed,cfsetispeed@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver cfsetospeed,cfsetospeed@" ORCA_GLIBC_COMPAT_VERSION);
+# ifdef __B38400
+# define ORCA_B38400 __B38400
+# else
+# define ORCA_B38400 B38400
+# endif
+static_assert(ORCA_B38400 == 0000017, "pinned cfset*speed take legacy CBAUD codes");
+# endif
+#endif
+#ifndef ORCA_B38400
+# define ORCA_B38400 B38400
+#endif
+
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -67,6 +103,9 @@
#include <sys/event.h>
#include <sys/sysctl.h>
#include <termios.h>
+#include <vis.h>
+#include <xlocale.h>
+#include <algorithm>
#endif
/* NSIG - macro for highest signal + 1, should be defined */
@@ -228,6 +267,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
static int
pty_nonblock(int);
+static int
+pty_cloexec(int);
+
#if defined(__APPLE__)
static char *
pty_getproc(int);
@@ -237,13 +279,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
+ int errnum;
+ std::string detail_name;
+ std::string detail_value;
+};
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error&);
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -345,8 +397,8 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
term->c_cc[VSTATUS] = 20;
#endif
- cfsetispeed(term, B38400);
- cfsetospeed(term, B38400);
+ cfsetispeed(term, ORCA_B38400);
+ cfsetospeed(term, ORCA_B38400);
// helperPath
std::string helper_path = info[9].As<Napi::String>();
@@ -367,14 +419,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
- int err = -1;
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
- if (err != 0) {
- throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
+ pty_spawn_error spawn_error = { NULL, 0, "", "" };
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error);
+ if (spawn_error.errnum != 0) {
+ std::string spawn_message = pty_format_spawn_error(spawn_error);
+ throw Napi::Error::New(napiEnv, spawn_message);
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
+ if (pty_cloexec(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
+ }
#else
int argc = argv_.Length();
int argl = argc + 2;
@@ -445,6 +501,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
+ if (pty_cloexec(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
+ }
}
#endif
@@ -586,6 +645,23 @@ pty_nonblock(int fd) {
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
+/**
+ * Orca: close-on-exec FD
+ *
+ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without
+ * FD_CLOEXEC is inherited by every later child of this process -- including
+ * later pty children -- which keeps its /dev/pts device and buffers alive long
+ * after its own session ends (#8362).
+ */
+
+static int
+pty_cloexec(int fd) {
+ int flags = fcntl(fd, F_GETFD);
+ if (flags == -1) return -1;
+ if (flags & FD_CLOEXEC) return 0;
+ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
+}
+
/**
* pty_getproc
* Taken from tmux.
@@ -684,15 +760,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
+ switch (errnum) {
+ case E2BIG: return "E2BIG";
+ case EACCES: return "EACCES";
+ case EAGAIN: return "EAGAIN";
+ case EMFILE: return "EMFILE";
+ case ENFILE: return "ENFILE";
+ case ENOENT: return "ENOENT";
+ case ENOMEM: return "ENOMEM";
+ default: return "errno";
+ }
+}
+
+static void
+pty_set_spawn_error(pty_spawn_error* err,
+ const char* step,
+ int errnum,
+ const char* detail_name = NULL,
+ const char* detail_value = NULL) {
+ err->step = step;
+ err->errnum = errnum;
+ err->detail_name = detail_name ? detail_name : "";
+ err->detail_value = detail_value ? detail_value : "";
+}
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error& err) {
+ char errno_buf[64];
+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum);
+
+ std::string message = "node-pty: ";
+ message += err.step ? err.step : "unknown";
+ message += " failed: ";
+ message += pty_errno_name(err.errnum);
+ message += " (errno ";
+ message += errno_buf;
+ message += ", ";
+ message += strerror(err.errnum);
+ message += ")";
+
+ if (!err.detail_name.empty()) {
+ message += " - ";
+ message += err.detail_name;
+ message += "='";
+ message += err.detail_value;
+ message += "'";
+ }
+
+ return message;
+}
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err) {
- int low_fds[3];
+ pty_spawn_error* err) {
+ int low_fds[3] = {-1, -1, -1};
size_t count = 0;
+ int res = -1;
+ int slave = -1;
+ posix_spawn_file_actions_t acts;
+ bool acts_initialized = false;
+ posix_spawnattr_t attrs;
+ bool attrs_initialized = false;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,84 +840,499 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
+ }
+
+ res = unlockpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
- int slave;
char slave_pty_name[128];
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
+ goto done;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
- return;
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
+ goto done;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name);
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name);
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res);
+ goto done;
+ }
+ acts_initialized = true;
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
- posix_spawnattr_t attrs;
- posix_spawnattr_init(&attrs);
- *err = posix_spawnattr_setflags(&attrs, flags);
- if (*err != 0) {
+ res = posix_spawnattr_init(&attrs);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_init", res);
+ goto done;
+ }
+ attrs_initialized = true;
+ res = posix_spawnattr_setflags(&attrs, flags);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigmask(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
goto done;
}
do
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
- while (*err == EINTR);
+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
+ while (res == EINTR);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]);
+ }
done:
- posix_spawn_file_actions_destroy(&acts);
- posix_spawnattr_destroy(&attrs);
+ if (acts_initialized) {
+ posix_spawn_file_actions_destroy(&acts);
+ }
+ if (attrs_initialized) {
+ posix_spawnattr_destroy(&attrs);
+ }
+ if (slave != -1) {
+ close(slave);
+ }
+ if (err->errnum != 0 && *master != -1) {
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
+ if (low_fds[i] != -1) {
+ close(low_fds[i]);
+ }
}
}
#endif
+#if defined(__APPLE__)
+/**
+ * Orca: in-process libproc/sysctl process table
+ *
+ * /bin/ps is setuid root, so every exec triggers an uncached syspolicyd
+ * assessment; pollers read the table here instead (#31783). Commands are
+ * rendered exactly as adv_cmds ps does (getproclline + strvis).
+ */
+
+// adv_cmds ps/print.c getproclline() with show_args and without -E.
+static bool
+orca_read_process_args(pid_t pid, std::vector<char>& buf, std::string* out) {
+ int mib[3] = { CTL_KERN, KERN_PROCARGS2, pid };
+ size_t size = buf.size();
+ if (size < sizeof(int) || sysctl(mib, 3, buf.data(), &size, NULL, 0) != 0 ||
+ size < sizeof(int) || size > buf.size()) {
+ return false;
+ }
+ char* procargs = buf.data();
+ char* end = procargs + size;
+ int nargs;
+ memcpy(&nargs, procargs, sizeof(nargs));
+ char* cp = procargs + sizeof(nargs);
+ while (cp < end && *cp != '\0') cp++;
+ if (cp == end) return false;
+ while (cp < end && *cp == '\0') cp++;
+ if (cp == end) return false;
+ char* sp = cp;
+ char* np = NULL;
+ for (int c = 0; c < nargs && cp < end; cp++) {
+ if (*cp == '\0') {
+ c++;
+ if (np != NULL) *np = ' ';
+ np = cp;
+ }
+ }
+ if (np == NULL || np == sp) return false;
+ out->assign(sp, strnlen(sp, end - sp));
+ return true;
+}
+
+// ps vis-encodes the whole command (print.c get_command_and_or_args).
+static bool
+orca_vis_command(const std::string& raw, std::string* out) {
+ // ps sizes strvis output at 4x; strnvis encodes the same but fails instead of overflowing.
+ if (raw.size() > (SIZE_MAX - 1) / 4) return false;
+ std::vector<char> encoded(raw.size() * 4 + 1);
+ int length = strnvis(encoded.data(), encoded.size(), raw.c_str(), VIS_TAB | VIS_NL | VIS_NOSLASH);
+ if (length < 0 || (size_t)length >= encoded.size()) return false;
+ out->assign(encoded.data(), length);
+ return true;
+}
+
+static bool
+orca_read_kinfo(int what, int flag, std::vector<struct kinfo_proc>* procs) {
+ int mib[4] = { CTL_KERN, KERN_PROC, what, flag };
+ u_int mib_length = what == KERN_PROC_ALL ? 3 : 4;
+ for (int attempt = 0; attempt < 8; attempt++) {
+ size_t size = 0;
+ if (sysctl(mib, mib_length, NULL, &size, NULL, 0) != 0) return false;
+ size += size / 8 + sizeof(struct kinfo_proc) * 16;
+ procs->resize(size / sizeof(struct kinfo_proc));
+ if (sysctl(mib, mib_length, procs->data(), &size, NULL, 0) == 0) {
+ procs->resize(size / sizeof(struct kinfo_proc));
+ return true;
+ }
+ if (errno != ENOMEM) return false;
+ }
+ return false;
+}
+
+static bool
+orca_read_kinfo_pid(pid_t pid, struct kinfo_proc* proc) {
+ int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, pid };
+ size_t size = sizeof(*proc);
+ return sysctl(mib, 4, proc, &size, NULL, 0) == 0 && size == sizeof(*proc) &&
+ proc->kp_proc.p_pid == pid;
+}
+
+static bool
+orca_is_char_device(const char* path, dev_t* dev) {
+ struct stat sb;
+ if (stat(path, &sb) != 0 || !S_ISCHR(sb.st_mode)) return false;
+ *dev = sb.st_rdev;
+ return true;
+}
+
+// adv_cmds ps/ps.c addelem_tty(): "/dev/ttys001", "ttys001", "s001" and "co" all name a terminal.
+static bool
+orca_resolve_tty(const std::string& elem, dev_t* dev) {
+ if (elem.empty()) return false;
+ std::string path;
+ if (elem[0] == '/') {
+ path = elem;
+ } else if (elem == "co") {
+ path = _PATH_CONSOLE;
+ } else {
+ path = std::string(_PATH_DEV) + elem;
+ bool literal = path.compare(0, strlen(_PATH_TTY), _PATH_TTY) == 0 || path == _PATH_CONSOLE;
+ if (!literal && orca_is_char_device((std::string(_PATH_TTY) + elem).c_str(), dev)) {
+ return true;
+ }
+ }
+ return access(path.c_str(), F_OK) == 0 && orca_is_char_device(path.c_str(), dev);
+}
+
+struct orca_process_row {
+ struct kinfo_proc proc;
+ std::string command;
+ // argv was unreadable (another user's, exiting or mid-exec): setuid ps may print more.
+ bool command_incomplete;
+};
+
+struct orca_process_query {
+ bool include_command;
+ bool select_pids;
+ std::vector<pid_t> pids;
+ bool select_tty;
+ std::string tty;
+ std::string ctype;
+};
+
+static std::string
+orca_comm_command(const struct kinfo_proc& proc) {
+ return "(" + std::string(proc.kp_proc.p_comm, strnlen(proc.kp_proc.p_comm, sizeof(proc.kp_proc.p_comm))) + ")";
+}
+
+// Unreadable argv prints "(comm)" as ps does when KERN_PROCARGS2 fails; no path lookup, so a dead mount cannot hang it.
+static void
+orca_fill_command(const struct kinfo_proc& proc, std::vector<char>& args_buf, orca_process_row* row) {
+ std::string raw;
+ if (proc.kp_proc.p_stat == SZOMB) {
+ raw = "<defunct>";
+ } else if (!orca_read_process_args(proc.kp_proc.p_pid, args_buf, &raw)) {
+ row->command = orca_comm_command(proc);
+ row->command_incomplete = true;
+ return;
+ }
+ if (!orca_vis_command(raw, &row->command)) {
+ row->command = orca_comm_command(proc);
+ row->command_incomplete = true;
+ }
+}
+
+// Runs on a worker thread: argv for ~2,000 processes costs ~17ms of syscalls.
+// Returns 0 on success, 1 when the -t terminal does not exist, -1 on failure.
+static int
+orca_gather_process_table(const orca_process_query& query, std::vector<orca_process_row>* rows) {
+ std::vector<struct kinfo_proc> procs;
+ if (query.select_pids) {
+ for (pid_t pid : query.pids) {
+ struct kinfo_proc proc;
+ if (orca_read_kinfo_pid(pid, &proc)) {
+ procs.push_back(proc);
+ }
+ }
+ } else if (query.select_tty) {
+ dev_t dev;
+ if (!orca_resolve_tty(query.tty, &dev)) return 1;
+ if (!orca_read_kinfo(KERN_PROC_TTY, (int)dev, &procs)) return -1;
+ } else if (!orca_read_kinfo(KERN_PROC_ALL, 0, &procs)) {
+ return -1;
+ }
+ std::vector<char> args_buf;
+ locale_t ctype = (locale_t)0;
+ locale_t previous = (locale_t)0;
+ if (query.include_command) {
+ int argmax = 0;
+ size_t argmax_size = sizeof(argmax);
+ int mib[2] = { CTL_KERN, KERN_ARGMAX };
+ if (sysctl(mib, 2, &argmax, &argmax_size, NULL, 0) != 0 || argmax <= 0) {
+ argmax = 1024 * 1024;
+ }
+ args_buf.resize(argmax);
+ // ps runs setlocale(LC_ALL, ""), so strvis escapes per the caller's LC_CTYPE; scope it to this thread.
+ ctype = newlocale(LC_CTYPE_MASK, query.ctype.c_str(), (locale_t)0);
+ if (ctype == (locale_t)0) ctype = newlocale(LC_CTYPE_MASK, "C", (locale_t)0);
+ if (ctype == (locale_t)0) return -1;
+ previous = uselocale(ctype);
+ if (previous == (locale_t)0) {
+ freelocale(ctype);
+ return -1;
+ }
+ }
+ rows->reserve(procs.size());
+ for (const struct kinfo_proc& proc : procs) {
+ if (proc.kp_proc.p_pid <= 0) continue;
+ orca_process_row row = { proc, "", false };
+ if (query.include_command) {
+ orca_fill_command(proc, args_buf, &row);
+ }
+ rows->push_back(std::move(row));
+ }
+ if (query.include_command) {
+ uselocale(previous);
+ freelocale(ctype);
+ }
+ return 0;
+}
+
+static Napi::Array
+orca_process_rows_to_js(Napi::Env env, bool include_command, const std::vector<orca_process_row>& rows) {
+ Napi::Array result = Napi::Array::New(env, rows.size());
+ uint32_t index = 0;
+ for (const orca_process_row& entry : rows) {
+ const struct kinfo_proc& proc = entry.proc;
+ Napi::Object row = Napi::Object::New(env);
+ row.Set("pid", Napi::Number::New(env, proc.kp_proc.p_pid));
+ row.Set("ppid", Napi::Number::New(env, proc.kp_eproc.e_ppid));
+ row.Set("pgid", Napi::Number::New(env, proc.kp_eproc.e_pgid));
+ row.Set("tpgid", Napi::Number::New(env, proc.kp_eproc.e_tpgid));
+ row.Set("tdev", Napi::Number::New(env, proc.kp_eproc.e_tdev));
+ row.Set("status", Napi::Number::New(env, proc.kp_proc.p_stat));
+ row.Set("flags", Napi::Number::New(env, proc.kp_proc.p_flag));
+ row.Set("nice", Napi::Number::New(env, proc.kp_proc.p_nice));
+ row.Set("uid", Napi::Number::New(env, proc.kp_eproc.e_ucred.cr_uid));
+ row.Set("sessionLeader", Napi::Boolean::New(env, (proc.kp_eproc.e_flag & EPROC_SLEADER) != 0));
+ row.Set("startSec", Napi::Number::New(env, (double)proc.kp_proc.p_starttime.tv_sec));
+ row.Set("startUsec", Napi::Number::New(env, (double)proc.kp_proc.p_starttime.tv_usec));
+ row.Set("comm", Napi::String::New(env, proc.kp_proc.p_comm, strnlen(proc.kp_proc.p_comm, sizeof(proc.kp_proc.p_comm))));
+ if (include_command) {
+ row.Set("command", Napi::String::New(env, entry.command));
+ row.Set("commandIncomplete", Napi::Boolean::New(env, entry.command_incomplete));
+ }
+ result.Set(index++, row);
+ }
+ return result;
+}
+
+// readProcessTable({ command, pids?, tty?, ctype? }) -> rows | null: pids and tty are exclusive selections, as Orca issues them.
+static orca_process_query
+orca_read_process_query(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ if (info.Length() < 1 || !info[0].IsObject()) {
+ throw Napi::TypeError::New(env, "Usage: readProcessTable({ command, pids, tty, ctype })");
+ }
+ Napi::Object options = info[0].As<Napi::Object>();
+ orca_process_query query = { false, false, {}, false, "", "C" };
+ query.include_command = options.Get("command").ToBoolean().Value();
+ Napi::Value pids = options.Get("pids");
+ if (pids.IsArray()) {
+ Napi::Array list = pids.As<Napi::Array>();
+ for (uint32_t i = 0; i < list.Length(); i++) {
+ Napi::Value pid = list.Get(i);
+ double value = pid.IsNumber() ? pid.As<Napi::Number>().DoubleValue() : 0;
+ if (!(value >= 1 && value <= INT32_MAX) || value != (double)(int32_t)value) {
+ throw Napi::TypeError::New(env, "readProcessTable: pids must be positive integers");
+ }
+ pid_t candidate = (pid_t)value;
+ if (std::find(query.pids.begin(), query.pids.end(), candidate) == query.pids.end()) {
+ query.pids.push_back(candidate);
+ }
+ }
+ query.select_pids = true;
+ }
+ Napi::Value tty = options.Get("tty");
+ if (tty.IsString()) {
+ if (query.select_pids) {
+ throw Napi::TypeError::New(env, "readProcessTable: pids and tty are exclusive");
+ }
+ query.tty = tty.As<Napi::String>().Utf8Value();
+ query.select_tty = true;
+ }
+ Napi::Value ctype = options.Get("ctype");
+ if (ctype.IsString()) {
+ query.ctype = ctype.As<Napi::String>().Utf8Value();
+ }
+ return query;
+}
+
+class OrcaProcessTableWorker : public Napi::AsyncWorker {
+ public:
+ OrcaProcessTableWorker(Napi::Env env, orca_process_query query)
+ : Napi::AsyncWorker(env),
+ deferred_(Napi::Promise::Deferred::New(env)),
+ query_(std::move(query)),
+ status_(-1) {}
+
+ Napi::Promise Promise() { return deferred_.Promise(); }
+
+ void Execute() override {
+ status_ = orca_gather_process_table(query_, &rows_);
+ if (status_ < 0) {
+ SetError("orca: process table sysctl failed");
+ }
+ }
+
+ void OnOK() override {
+ Napi::Env env = Env();
+ // ps exits 1 without output when -t names no terminal.
+ deferred_.Resolve(status_ == 1 ? env.Null()
+ : Napi::Value(orca_process_rows_to_js(env, query_.include_command, rows_)));
+ }
+
+ void OnError(const Napi::Error& error) override {
+ deferred_.Reject(error.Value());
+ }
+
+ private:
+ Napi::Promise::Deferred deferred_;
+ orca_process_query query_;
+ int status_;
+ std::vector<orca_process_row> rows_;
+};
+
+Napi::Value OrcaProcessTableAsync(const Napi::CallbackInfo& info) {
+ OrcaProcessTableWorker* worker = new OrcaProcessTableWorker(info.Env(), orca_read_process_query(info));
+ Napi::Promise promise = worker->Promise();
+ worker->Queue();
+ return promise;
+}
+
+// PROC_PIDVNODEPATHINFO stats the cwd vnode and proc_pidpath may ask the filesystem for a path, so either can block
+// on a dead network mount: both run on a worker thread and JS bounds how many may be outstanding.
+class OrcaProcessPathWorker : public Napi::AsyncWorker {
+ public:
+ OrcaProcessPathWorker(Napi::Env env, pid_t pid, bool cwd)
+ : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)), pid_(pid), cwd_(cwd), found_(false) {}
+
+ Napi::Promise Promise() { return deferred_.Promise(); }
+
+ void Execute() override {
+ if (cwd_) {
+ struct proc_vnodepathinfo vnode;
+ int size = proc_pidinfo(pid_, PROC_PIDVNODEPATHINFO, 0, &vnode, sizeof(vnode));
+ if (size == (int)sizeof(vnode) && vnode.pvi_cdir.vip_path[0] != '\0') {
+ path_.assign(vnode.pvi_cdir.vip_path, strnlen(vnode.pvi_cdir.vip_path, sizeof(vnode.pvi_cdir.vip_path)));
+ found_ = true;
+ }
+ return;
+ }
+ char path[PROC_PIDPATHINFO_MAXSIZE];
+ int length = proc_pidpath(pid_, path, sizeof(path));
+ if (length > 0 && (size_t)length < sizeof(path)) {
+ path_.assign(path, length);
+ found_ = true;
+ }
+ }
+
+ void OnOK() override {
+ Napi::Env env = Env();
+ deferred_.Resolve(found_ ? Napi::Value(Napi::String::New(env, path_)) : env.Null());
+ }
+
+ void OnError(const Napi::Error& error) override {
+ deferred_.Reject(error.Value());
+ }
+
+ private:
+ Napi::Promise::Deferred deferred_;
+ pid_t pid_;
+ bool cwd_;
+ bool found_;
+ std::string path_;
+};
+
+static Napi::Value
+orca_queue_path_worker(const Napi::CallbackInfo& info, bool cwd) {
+ Napi::Env env(info.Env());
+ if (info.Length() < 1 || !info[0].IsNumber()) {
+ throw Napi::TypeError::New(env, cwd ? "Usage: readProcessCwd(pid)" : "Usage: readProcessPath(pid)");
+ }
+ OrcaProcessPathWorker* worker = new OrcaProcessPathWorker(env, info[0].As<Napi::Number>().Int32Value(), cwd);
+ Napi::Promise promise = worker->Promise();
+ worker->Queue();
+ return promise;
+}
+
+Napi::Value OrcaReadProcessCwd(const Napi::CallbackInfo& info) {
+ return orca_queue_path_worker(info, true);
+}
+
+Napi::Value OrcaReadProcessPath(const Napi::CallbackInfo& info) {
+ return orca_queue_path_worker(info, false);
+}
+#endif
+
/**
* Init
*/
@@ -793,6 +1342,11 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("open", Napi::Function::New(env, PtyOpen));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("process", Napi::Function::New(env, PtyGetProc));
+#if defined(__APPLE__)
+ exports.Set("readProcessTable", Napi::Function::New(env, OrcaProcessTableAsync));
+ exports.Set("readProcessCwd", Napi::Function::New(env, OrcaReadProcessCwd));
+ exports.Set("readProcessPath", Napi::Function::New(env, OrcaReadProcessPath));
+#endif
return exports;
}
diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts
index 98733dc0cd752b554bd94e45904ca341ad141bba..3dd5ad9b3124dbd5ba7f76679b26650dad503ec6 100644
--- a/src/unixTerminal.ts
+++ b/src/unixTerminal.ts
@@ -306,6 +306,14 @@ interface IWriteTask {
offset: number;
}
+/**
+ * Orca: upstream retries EAGAIN with `setImmediate`, which re-attempts within microseconds
+ * and pins a core on the daemon thread when a reader stops draining. 1ms keeps ~97% of that
+ * CPU saving; longer delays cost 2-3x delivery time to a reader that drains in bursts (an
+ * agent). Re-measure both before changing.
+ */
+const EAGAIN_RETRY_DELAY_MS = 1;
+
/**
* A custom write stream that writes directly to a file descriptor with proper
* handling of backpressure and errors. This avoids some event loop exhaustion
@@ -314,20 +322,28 @@ interface IWriteTask {
class CustomWriteStream implements IDisposable {
private readonly _writeQueue: IWriteTask[] = [];
- private _writeImmediate: NodeJS.Immediate | undefined;
+ private _writeImmediate: NodeJS.Timeout | undefined;
constructor(
- private readonly _fd: number,
+ private _fd: number,
private readonly _encoding: BufferEncoding
) {
}
dispose(): void {
- clearImmediate(this._writeImmediate);
+ clearTimeout(this._writeImmediate);
this._writeImmediate = undefined;
+ // Orca: retire this stream's own copy of the master fd and drop what has
+ // not shipped, so nothing queued here reaches a reused descriptor.
+ this._fd = -1;
+ this._writeQueue.length = 0;
}
write(data: string | Buffer): void {
+ if (this._fd < 0) {
+ return;
+ }
+
// Writes are put in a queue and processed asynchronously in order to handle
// backpressure from the kernel buffer.
const buffer = typeof data === 'string'
@@ -345,7 +361,8 @@ class CustomWriteStream implements IDisposable {
private _processWriteQueue(): void {
this._writeImmediate = undefined;
- if (this._writeQueue.length === 0) {
+ // Orca: an in-flight fs.write can re-enter here after dispose().
+ if (this._fd < 0 || this._writeQueue.length === 0) {
return;
}
@@ -357,9 +374,9 @@ class CustomWriteStream implements IDisposable {
fs.write(this._fd, task.buffer, task.offset, (err, written) => {
if (err) {
if ('code' in err && err.code === 'EAGAIN') {
- // `setImmediate` is used to yield to the event loop and re-attempt
- // the write later.
- this._writeImmediate = setImmediate(() => this._processWriteQueue());
+ // Paced, not `setImmediate`: a stalled reader keeps this branch EAGAIN-ing, and an
+ // immediate re-attempt turns the retry into a busy-loop on the daemon thread.
+ this._writeImmediate = setTimeout(() => this._processWriteQueue(), EAGAIN_RETRY_DELAY_MS);
} else {
// Stop processing immediately on unexpected error and log
this._writeQueue.length = 0;
diff --git a/src/utils.ts b/src/utils.ts
index 81a70c77a18e25c50b26f0d80aa0e8d8c2672805..4bc8d760fe00e208f211570930cd163b77901810 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -10,8 +10,8 @@ export function assign(target: any, ...sources: any[]): any {
export function loadNativeModule(name: string): {dir: string, module: any} {
- // Check build, debug, and then prebuilds.
- const dirs = ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`];
+ // Check Orca's per-ABI build (published into package dirs shared across worktrees), build, debug, and then prebuilds.
+ const dirs = [`build/orca-napi-${process.platform}-${process.arch}`, 'build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`];
// Check relative to the parent dir for unbundled and then the current dir for bundled
const relative = ['..', '.'];
let lastError: unknown;
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d64..5239ba4e40 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,8 @@
#include <iostream>
#include <string>
#include <thread>
+#include <mutex>
+#include <atomic>
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,15 +46,39 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
- HANDLE hShell;
+ HANDLE hShell = nullptr;
+ // Orca: the shell's pid, captured at spawn. The ownership guard compares
+ // against this rather than calling GetProcessId(hShell), because the exit
+ // watcher closes hShell on another thread -- reading it there is an
+ // invalid-handle operation, and under strict handle checks that is fatal.
+ DWORD shellPid = 0;
+
+ // Orca: job object owning this pty's whole process tree. Null when the OS
+ // refused to create or assign one (an outer job without breakaway rights),
+ // in which case callers fall back to their pre-job behaviour.
+ HANDLE hJob = nullptr;
+ bool allowJobBreakaway = true;
+
+ // Orca: teardown needs BOTH the shell's death and an explicit kill() before
+ // the baton can be freed, so each side records that it has run. Whichever
+ // arrives second frees it. Freeing on the shell's death alone -- what this
+ // file did before -- destroyed the only record of `hpc` while
+ // ClosePseudoConsole was still owed, which is why a self-exiting shell
+ // leaked its pseudoconsole and the console host it reaps (#18601 / F24).
+ bool shellExited = false;
+ bool consoleClosed = false;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
+// Orca: every table access shares the exit watcher's lock; handles can be recycled.
+static std::mutex ptyJobMutex;
+// Orca: 0 = no per-terminal ceiling (the default); set once at daemon start.
+static std::atomic<uint32_t> ptyActiveProcessLimit{0};
static volatile LONG ptyCounter;
-static pty_baton* get_pty_baton(int id) {
+static pty_baton* get_pty_baton_locked(int id) {
auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
@@ -62,7 +88,7 @@ static pty_baton* get_pty_baton(int id) {
return nullptr;
}
-static bool remove_pty_baton(int id) {
+static bool remove_pty_baton_locked(int id) {
auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
@@ -102,8 +128,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
- CloseHandle(baton->hShell);
- assert(remove_pty_baton(baton->id));
+ // Orca: release the job once the shell is gone. Without kill-on-close this
+ // only frees the handle -- anything the user backgrounded is orphaned, as
+ // it was before this patch.
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ CloseHandle(baton->hShell);
+ baton->hShell = nullptr;
+ if (baton->hJob != nullptr) {
+ CloseHandle(baton->hJob);
+ baton->hJob = nullptr;
+ }
+ // Why inside the lock: erasing frees the baton the job accessors hold a
+ // pointer to. Note remove_pty_baton must not be an assert() argument --
+ // NDEBUG would compile the call away and leak every baton.
+ baton->shellExited = true;
+ if (baton->consoleClosed) {
+ const bool removed = remove_pty_baton_locked(baton->id);
+ assert(removed);
+ (void)removed;
+ }
+ // Else PtyKill has not run yet and still owns hpc. It frees the baton.
+ }
+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the
+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding
+ // the lock across it deadlocks. Do not widen this scope.
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -242,6 +291,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
return HRESULT_FROM_WIN32(GetLastError());
}
+// Cygwin and MSYS request breakaway for every child whenever the job allows it,
+// so their shells need one that does not. The runtime DLL on the exe's search
+// path is the signal; Git for Windows ships bash.exe in bin\ beside usr\bin\.
+static bool usesCygwinRuntime(const std::wstring& shellpath) {
+ const size_t separator = shellpath.find_last_of(L"\\/");
+ if (separator == std::wstring::npos) return false;
+ const std::wstring directory = shellpath.substr(0, separator + 1);
+ for (const wchar_t* dll : {L"msys-2.0.dll", L"cygwin1.dll"}) {
+ if (path_util::file_exists(directory + dll) ||
+ path_util::file_exists(directory + L"..\\usr\\bin\\" + dll)) return true;
+ }
+ return false;
+}
+
static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
@@ -301,8 +364,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
// We were able to instantiate a conpty
const int ptyId = InterlockedIncrement(&ptyCounter);
marshal.Set("pty", Napi::Number::New(env, ptyId));
- ptyHandles.emplace_back(
- std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
+ auto baton = std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc);
+ baton->allowJobBreakaway = !usesCygwinRuntime(shellpath);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ ptyHandles.emplace_back(std::move(baton));
} else {
throw Napi::Error::New(env, "Cannot launch conpty");
}
@@ -350,11 +415,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
const bool useConptyDll = info[4].As<Napi::Boolean>().Value();
Napi::Function exitCallback = info[5].As<Napi::Function>();
- // Fetch pty handle from ID and start process
- pty_baton* handle = get_pty_baton(id);
- if (!handle) {
- throw Napi::Error::New(env, "Invalid pty handle");
+ pty_baton* handle;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ handle = get_pty_baton_locked(id);
+ if (!handle || handle->consoleClosed) {
+ throw Napi::Error::New(env, "Invalid pty handle");
+ }
}
+ // No watcher exists for this baton until SetupExitCallback below; pipe waits stay unlocked.
// Prepare command line
std::unique_ptr<wchar_t[]> mutableCommandline = std::make_unique<wchar_t[]>(cmdline.length() + 1);
@@ -409,6 +478,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
+ // Orca: resolve the DLL BEFORE creating anything. It throws when conpty.dll
+ // is missing -- a real state, and one this branch hit during development --
+ // and every throw between CreateProcessW and SetupExitCallback leaks the job,
+ // process and thread handles AND leaves an untracked shell tree running,
+ // once per attempt. Validating first means the only throw after creation is
+ // the resume failure, which cleans up after itself.
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
+ bool fLoadedDll = hLibrary != nullptr;
+
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +494,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
- EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags
+ // Orca: CREATE_SUSPENDED so the shell is inside its job before it can
+ // spawn anything. Assigning after the fact leaves a window in which a
+ // fast child escapes the job and outlives the pane.
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, // dwCreationFlags
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +507,54 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
+ // Orca: own the tree with a handle instead of inferring it later from a
+ // parent-pid walk. A pid walk cannot survive pid reuse and cannot see a
+ // descendant that reparented, which is why detached agent children outlived
+ // their pane and held the worktree directory open.
+ //
+ // Deliberately WITHOUT JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Measured on
+ // Windows 11: with that flag, closing the handle when the shell exits also
+ // kills whatever the user left running, so typing `exit` in a pane reaped a
+ // `start /b` server that used to survive. This job exists to make an
+ // EXPLICIT teardown exact, not to redefine what a clean exit means.
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr) {
+ // Native shells retain explicit breakaway for installers and updaters.
+ // Cygwin/MSYS shells take it automatically for ordinary children whenever
+ // this flag is present, so they get strict per-PTY membership instead.
+ // Explicit breakaway requests inside such a pane are consequently denied;
+ // ordinary backgrounding and clean shell exit remain supported.
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
+ jobLimits.BasicLimitInformation.LimitFlags =
+ handle->allowJobBreakaway ? JOB_OBJECT_LIMIT_BREAKAWAY_OK : 0;
+ // Orca: an opt-in ceiling; forks past it fail inside this terminal's tree only.
+ const uint32_t activeProcessLimit = ptyActiveProcessLimit.load();
+ if (activeProcessLimit > 0) {
+ jobLimits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
+ jobLimits.BasicLimitInformation.ActiveProcessLimit = activeProcessLimit;
+ }
+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) ||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK
+ // (some EDR and container hosts) refuses the assignment. The pty must
+ // still start; ownership just degrades to the older best-effort path.
+ CloseHandle(hJob);
+ hJob = nullptr;
+ }
+ }
+ // Safe to run now: either it is in the job, or we accepted that it is not.
+ if (ResumeThread(piClient.hThread) == static_cast<DWORD>(-1)) {
+ // Why fatal: a shell left suspended produces a pane that never prints and
+ // never exits, which is far harder to diagnose than a failed spawn.
+ if (hJob != nullptr) {
+ CloseHandle(hJob);
+ }
+ TerminateProcess(piClient.hProcess, 1);
+ CloseHandle(piClient.hProcess);
+ CloseHandle(piClient.hThread);
+ throw errorWithCode(info, "Cannot resume process");
+ }
+
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -438,8 +565,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
}
}
- // Update handle
- handle->hShell = piClient.hProcess;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ handle->hShell = piClient.hProcess;
+ handle->shellPid = piClient.dwProcessId;
+ handle->hJob = hJob;
+ }
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -472,9 +603,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) {
SHORT rows = static_cast<SHORT>(info[2].As<Napi::Number>().Uint32Value());
const bool useConptyDll = info[3].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(id);
- if (handle != nullptr) {
+ if (handle != nullptr && !handle->consoleClosed) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
@@ -513,9 +645,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) {
return env.Undefined();
}
- const pty_baton* handle = get_pty_baton(id);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(id);
- if (handle != nullptr) {
+ if (handle != nullptr && !handle->consoleClosed) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
@@ -544,29 +677,239 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
+ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason
+ // PtyConnect does it before creating anything. LoadConptyDll throws when
+ // conpty.dll is missing, and a throw after consoleClosed was set would strand
+ // the pseudoconsole permanently: the retry would find the work already
+ // claimed and do nothing. Only the useConptyDll path can throw here; the
+ // other returns kernel32.
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
+ PFNCLOSEPSEUDOCONSOLE pfnClosePseudoConsole = nullptr;
+ if (hLibrary != nullptr) {
+ pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
+ (HMODULE)hLibrary,
+ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
+ }
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
- {
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
+ // Orca: the baton now outlives the shell, so this runs on a self-exited pty
+ // too -- that is the whole point. Take what we need under the lock: the
+ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess
+ // on a handle it just closed is an invalid-handle operation. Duplicating
+ // rather than reordering keeps upstream's close-then-terminate sequence.
+ HPCON hpc = nullptr;
+ HANDLE hShellDup = nullptr;
+ bool owed = false;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ pty_baton* handle = get_pty_baton_locked(id);
+ // Why the consoleClosed check: a second kill() would otherwise close the
+ // same pseudoconsole twice. Upstream relied on the baton being gone.
+ if (handle != nullptr && !handle->consoleClosed) {
+ hpc = handle->hpc;
+ owed = true;
+ handle->consoleClosed = true;
+ // Null hShell means a self-exited pty, where there is nothing to kill.
+ if (useConptyDll && handle->hShell != nullptr) {
+ if (!DuplicateHandle(GetCurrentProcess(), handle->hShell, GetCurrentProcess(),
+ &hShellDup, 0, FALSE, DUPLICATE_SAME_ACCESS)) {
+ // Why terminate here instead of skipping: a failed duplication leaves
+ // hShellDup null, which is indistinguishable from the self-exit case,
+ // and skipping would leave the shell RUNNING after its pane closed --
+ // a worse outcome than the leak this all exists to fix. hShell is
+ // valid under this lock and TerminateProcess does not block, so the
+ // only cost is that this rare path kills before the console closes.
+ hShellDup = nullptr;
+ TerminateProcess(handle->hShell, 1);
+ }
}
+ if (handle->shellExited) {
+ const bool removed = remove_pty_baton_locked(id);
+ assert(removed);
+ (void)removed;
+ }
+ // Else the shell is still running and the watcher frees the baton.
+ }
+ }
+
+ // Why outside the lock: ClosePseudoConsole blocks until the conout side has
+ // drained, and the watcher must be able to take the lock while it does.
+ if (owed) {
+ if (pfnClosePseudoConsole)
+ {
+ pfnClosePseudoConsole(hpc);
}
- if (useConptyDll) {
- TerminateProcess(handle->hShell, 1);
+ if (hShellDup != nullptr) {
+ TerminateProcess(hShellDup, 1);
+ CloseHandle(hShellDup);
}
}
return env.Undefined();
}
+/**
+ * Orca: confirm a baton really is the pty the caller means.
+ *
+ * The winpty backend mints its own `pty` ids from a separate counter, and the
+ * JS layer stores both in the same field -- so a winpty terminal's id can
+ * collide with a live ConPTY baton here and terminate an unrelated pane's whole
+ * process tree. Matching the shell pid makes the id unforgeable.
+ */
+static bool ownsShell(const pty_baton* handle, DWORD expectedShellPid) {
+ return handle != nullptr && handle->hJob != nullptr && expectedShellPid != 0 &&
+ handle->shellPid == expectedShellPid;
+}
+
+/**
+ * Orca: kill this pty's entire tree in one syscall.
+ *
+ * Replaces "scrape the process table, walk parent pids, hope none were
+ * recycled, then taskkill /T /F". Returns false when no job was assigned so
+ * the caller knows to fall back rather than assume the tree is gone.
+ */
+static Napi::Value PtyTerminateJob(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return Napi::Boolean::New(env, false);
+ }
+ return Napi::Boolean::New(env, !!TerminateJobObject(handle->hJob, 1));
+}
+
+/**
+ * Orca: the pids still alive in this pty's tree, straight from the kernel.
+ *
+ * Descendant liveness for a tree that is still tracked, including children that
+ * detached from the console. Once the shell exits the watcher nulls hJob, which
+ * ownsShell rejects, so this returns null rather than an empty list -- null
+ * means "no answer", never "they died". (The baton itself now outlives the
+ * shell, until kill() runs; hJob is what makes the answer null.) Also returns
+ * null when no job was assigned.
+ *
+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it
+ * before this job exists, so it is not a member and ClosePseudoConsole is what
+ * reaps it.
+ */
+static Napi::Value PtyListJobProcessIds(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.listJobProcessIds(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return env.Null();
+ }
+
+ // Grow until the buffer holds every pid: the count can change between calls,
+ // and a truncated list would read as "these children are gone".
+ DWORD capacity = 64;
+ for (int attempt = 0; attempt < 8; attempt++) {
+ const size_t bytes = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + sizeof(ULONG_PTR) * capacity;
+ std::vector<char> buffer(bytes, 0);
+ auto* list = reinterpret_cast<JOBOBJECT_BASIC_PROCESS_ID_LIST*>(buffer.data());
+ if (QueryInformationJobObject(handle->hJob, JobObjectBasicProcessIdList, list, static_cast<DWORD>(bytes), nullptr)) {
+ auto pids = Napi::Array::New(env, list->NumberOfProcessIdsInList);
+ for (DWORD i = 0; i < list->NumberOfProcessIdsInList; i++) {
+ pids.Set(i, Napi::Number::New(env, static_cast<double>(list->ProcessIdList[i])));
+ }
+ return pids;
+ }
+ if (GetLastError() != ERROR_MORE_DATA) {
+ return env.Null();
+ }
+ capacity *= 4;
+ }
+ return env.Null();
+}
+
+/**
+ * Orca: put THIS process in a kill-on-close job, so its whole descendant tree
+ * dies with it.
+ *
+ * Why here and not per-pty: a per-pty job cannot carry KILL_ON_JOB_CLOSE,
+ * because its handle is released when the shell exits and that would reap
+ * whatever the user had backgrounded. This job's handle is released only when
+ * the process itself dies, so it reaps a crashed host without changing what a
+ * clean shell exit means. Children inherit job membership, so every pty the
+ * caller later spawns is covered without further work, and the per-pty jobs
+ * simply nest inside this one.
+ *
+ * The handle is deliberately never closed: it must outlive every caller, and
+ * process teardown is what releases it.
+ */
+static Napi::Value AssignCurrentProcessToJob(const Napi::CallbackInfo& info, bool allowBreakaway) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ // Why locked: two callers racing here would each create a job, put the
+ // process in both, and leak the first handle -- and since the handle is what
+ // keeps a kill-on-close job alive, a leaked one is never released. A worker
+ // thread with its own N-API env shares these statics, so "only JS calls it"
+ // is not a guarantee.
+ static HANDLE hHostJob = nullptr;
+ static bool hostJobAllowsBreakaway = true;
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ if (hHostJob != nullptr) {
+ return Napi::Boolean::New(env, allowBreakaway || !hostJobAllowsBreakaway);
+ }
+
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
+ if (job == nullptr) {
+ return Napi::Boolean::New(env, false);
+ }
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
+ // Terminal hosts permit breakaway; native messaging supervisors retain
+ // ownership of every helper, including those requesting breakaway.
+ limits.BasicLimitInformation.LimitFlags =
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | (allowBreakaway ? JOB_OBJECT_LIMIT_BREAKAWAY_OK : 0);
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
+ // Incompatible outer jobs refuse ownership; strict callers fail closed.
+ CloseHandle(job);
+ return Napi::Boolean::New(env, false);
+ }
+ hHostJob = job;
+ hostJobAllowsBreakaway = allowBreakaway;
+ return Napi::Boolean::New(env, true);
+}
+
+static Napi::Value PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) {
+ return AssignCurrentProcessToJob(info, true);
+}
+
+static Napi::Value PtyAssignCurrentProcessToStrictJob(const Napi::CallbackInfo& info) {
+ return AssignCurrentProcessToJob(info, false);
+}
+
+/**
+ * Orca: setActiveProcessLimit(limit) applies JOB_OBJECT_LIMIT_ACTIVE_PROCESS to
+ * every ConPTY job created afterwards. 0 turns it off. Existing jobs keep theirs.
+ */
+static Napi::Value PtySetActiveProcessLimit(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+ if (info.Length() != 1 || !info[0].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.setActiveProcessLimit(limit)");
+ }
+ ptyActiveProcessLimit.store(info[0].As<Napi::Number>().Uint32Value());
+ return Napi::Boolean::New(env, true);
+}
+
/**
* Init
*/
@@ -577,6 +920,11 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
+ exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob));
+ exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds));
+ exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob));
+ exports.Set("assignCurrentProcessToStrictJob", Napi::Function::New(env, PtyAssignCurrentProcessToStrictJob));
+ exports.Set("setActiveProcessLimit", Napi::Function::New(env, PtySetActiveProcessLimit));
return exports;
};
diff --git a/lib/windowsConoutConnection.js b/lib/windowsConoutConnection.js
--- a/lib/windowsConoutConnection.js
+++ b/lib/windowsConoutConnection.js
@@ -74,6 +74,15 @@
};
var scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked');
this._worker = new worker_threads_1.Worker(path_1.join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData });
+ this._onFailed = new eventEmitter2_1.EventEmitter2();
+ // Orca: without a listener a dead worker's error is rethrown process-wide. Report the exit
+ // instead, so a relay that dies before it connects still ends its terminal.
+ this._worker.on('error', function () { });
+ this._worker.on('exit', function () {
+ if (!_this._isDisposed) {
+ _this._onFailed.fire();
+ }
+ });
this._worker.on('message', function (message) {
switch (message) {
case 1 /* READY */:
@@ -89,6 +98,11 @@
enumerable: false,
configurable: true
});
+ Object.defineProperty(ConoutConnection.prototype, "onFailed", {
+ get: function () { return this._onFailed.event; },
+ enumerable: false,
+ configurable: true
+ });
ConoutConnection.prototype.dispose = function () {
if (!this._useConptyDll && this._isDisposed) {
return;
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -75,6 +75,10 @@
this._conoutSocketWorker.onReady(function () {
_this._conoutSocketWorker.connectSocket(_this._outSocket);
});
+ // Orca: surface a dead relay through the output socket's own error path.
+ this._conoutSocketWorker.onFailed(function () {
+ _this._outSocket.destroy(new Error('ConPTY output relay exited'));
+ });
this._outSocket.on('connect', function () {
_this._outSocket.emit('ready_datapipe');
});
@@ -136,6 +140,9 @@
if (this._useConpty) {
if (!this._useConptyDll) {
this._inSocket.readable = false;
+ // The non-DLL path previously only flipped `readable`, leaving the
+ // conin PipeWrap alive until the host exited (#947).
+ this._inSocket.destroy();
this._outSocket.readable = false;
this._getConsoleProcessList().then(function (consoleProcessList) {
consoleProcessList.forEach(function (pid) {
@@ -154,9 +161,10 @@
// Close the input write handle to signal the end of session.
this._inSocket.destroy();
this._ptyNative.kill(this._pty, this._useConptyDll);
- this._outSocket.on('data', function () {
- _this._conoutSocketWorker.dispose();
- });
+ // Orca: dispose unconditionally, as the non-DLL branch above does.
+ // Waiting for another 'data' event leaks the conout worker on every
+ // self-exiting shell, because no more data ever arrives (F24).
+ this._conoutSocketWorker.dispose();
}
}
else {
@@ -182,6 +190,11 @@
var _this = this;
return new Promise(function (resolve) {
var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]);
+ // Orca: an unhandled fork or IPC error would be rethrown process-wide.
+ agent.on('error', function () {
+ clearTimeout(timeout);
+ resolve([_this._innerPid]);
+ });
agent.on('message', function (message) {
clearTimeout(timeout);
resolve(message.consoleProcessList);
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
--- a/lib/windowsTerminal.js
+++ b/lib/windowsTerminal.js
@@ -50,6 +50,29 @@
// Create new termal.
_this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
_this._socket = _this._agent.outSocket;
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
+ var outputCloseEmitsExit = false;
+ var killedBeforeReady = false;
+ _this._socket.on('error', function () {
+ // Orca: retire only this terminal. Rethrowing an unlisted code (write UNKNOWN, ECONNRESET)
+ // killed every PTY in the process; consumer listeners still receive the error.
+ _this._close();
+ if (_this._isReady || killedBeforeReady) {
+ return;
+ }
+ // Before the first output kill() stays deferred forever, so end the shell here.
+ killedBeforeReady = true;
+ try {
+ _this._agent.kill();
+ }
+ catch (_a) {
+ // The shell may already be gone; the terminal is retired either way.
+ }
+ // Before the relay connects no 'close' handler reports exit.
+ if (!outputCloseEmitsExit) {
+ _this.emit('exit', _this._agent.exitCode);
+ }
+ });
// Not available until `ready` event emitted.
_this._pid = _this._agent.innerPid;
_this._fd = _this._agent.fd;
@@ -57,6 +80,7 @@
// The forked windows terminal is not available until `ready` event is
// emitted.
_this._socket.on('ready_datapipe', function () {
+ outputCloseEmitsExit = true;
// Run deferreds and set ready state once the first data event is received.
_this._socket.once('data', function () {
// Wait until the first data event is fired then we can run deferreds.
@@ -76,23 +100,6 @@
_this._deferreds = [];
}
});
- // Shutdown if `error` event is emitted.
- _this._socket.on('error', function (err) {
- // Close terminal session.
- _this._close();
- // EIO, happens when someone closes our child process: the only process
- // in the terminal.
- // node < 0.6.14: errno 5
- // node >= 0.6.14: read EIO
- if (err.code) {
- if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))
- return;
- }
- // Throw anything else.
- if (_this.listeners('error').length < 2) {
- throw err;
- }
- });
// Cleanup after the socket is closed.
_this._socket.on('close', function () {
_this.emit('exit', _this._agent.exitCode);
@@ -103,6 +110,20 @@
_this._name = name;
_this._readable = true;
_this._writable = true;
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
+ _this._agent.inSocket.on('error', function () {
+ if (!_this._writable) {
+ return;
+ }
+ _this._close();
+ try {
+ _this._agent.kill();
+ }
+ catch (_a) {
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
+ }
+ });
_this._forwardEvents();
return _this;
}
@@ -196,4 +217,4 @@
return WindowsTerminal;
}(terminal_1.Terminal));
exports.WindowsTerminal = WindowsTerminal;
-//# sourceMappingURL=windowsTerminal.js.map
\ No newline at end of file
+//# sourceMappingURL=windowsTerminal.js.map
diff --git a/lib/worker/conoutSocketWorker.js b/lib/worker/conoutSocketWorker.js
--- a/lib/worker/conoutSocketWorker.js
+++ b/lib/worker/conoutSocketWorker.js
@@ -9,10 +9,33 @@
var conoutPipeName = worker_threads_1.workerData.conoutPipeName;
var conoutSocket = new net_1.Socket();
conoutSocket.setEncoding('utf8');
+// Orca: an unhandled stream error crashes this worker, and Node rethrows it on the main thread as
+// an uncaughtException that kills every PTY in the process (write EPIPE/UNKNOWN once the reader
+// closed: #26877, #33350). Contain errors per terminal and keep draining ConPTY output.
+var workerSockets = new Set();
+var drainConout = function () {
+ conoutSocket.resume();
+};
+conoutSocket.on('error', function () {
+ // Closing the reader ends this terminal instead of leaving it waiting for output.
+ workerSockets.forEach(function (workerSocket) { return workerSocket.destroy(); });
+});
conoutSocket.connect(conoutPipeName, function () {
var server = net_1.createServer(function (workerSocket) {
+ if (conoutSocket.destroyed) {
+ workerSocket.destroy();
+ return;
+ }
+ workerSockets.add(workerSocket);
+ workerSocket.on('error', function () { });
conoutSocket.pipe(workerSocket);
+ // After pipe() so its unpipe-and-pause runs first; an undrained conout can block ClosePseudoConsole.
+ workerSocket.on('close', function () {
+ workerSockets.delete(workerSocket);
+ drainConout();
+ });
});
+ server.on('error', drainConout);
server.listen(conout_1.getWorkerPipeName(conoutPipeName));
if (!worker_threads_1.parentPort) {
throw new Error('worker_threads parentPort is null');
diff --git a/src/windowsConoutConnection.ts b/src/windowsConoutConnection.ts
--- a/src/windowsConoutConnection.ts
+++ b/src/windowsConoutConnection.ts
@@ -35,6 +35,8 @@
private _onReady = new EventEmitter2<void>();
public get onReady(): IEvent<void> { return this._onReady.event; }
+ private _onFailed = new EventEmitter2<void>();
+ public get onFailed(): IEvent<void> { return this._onFailed.event; }
constructor(
private _conoutPipeName: string,
@@ -45,6 +47,14 @@
};
const scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked');
this._worker = new Worker(join(scriptPath, 'worker/conoutSocketWorker.js'), { workerData });
+ // Orca: without a listener a dead worker's error is rethrown process-wide. Report the exit
+ // instead, so a relay that dies before it connects still ends its terminal.
+ this._worker.on('error', () => {});
+ this._worker.on('exit', () => {
+ if (!this._isDisposed) {
+ this._onFailed.fire();
+ }
+ });
this._worker.on('message', (message: ConoutWorkerMessage) => {
switch (message) {
case ConoutWorkerMessage.READY:
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -103,6 +103,10 @@
this._conoutSocketWorker.onReady(() => {
this._conoutSocketWorker.connectSocket(this._outSocket);
});
+ // Orca: surface a dead relay through the output socket's own error path.
+ this._conoutSocketWorker.onFailed(() => {
+ this._outSocket.destroy(new Error('ConPTY output relay exited'));
+ });
this._outSocket.on('connect', () => {
this._outSocket.emit('ready_datapipe');
});
@@ -143,6 +147,9 @@
if (this._useConpty) {
if (!this._useConptyDll) {
this._inSocket.readable = false;
+ // The non-DLL path previously only flipped `readable`, leaving the
+ // conin PipeWrap alive until the host exited (#947).
+ this._inSocket.destroy();
this._outSocket.readable = false;
this._getConsoleProcessList().then(consoleProcessList => {
consoleProcessList.forEach((pid: number) => {
@@ -159,9 +166,10 @@
// Close the input write handle to signal the end of session.
this._inSocket.destroy();
(this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll);
- this._outSocket.on('data', () => {
- this._conoutSocketWorker.dispose();
- });
+ // Orca: dispose unconditionally, as the non-DLL branch above does.
+ // Waiting for another 'data' event leaks the conout worker on every
+ // self-exiting shell, because no more data ever arrives (F24).
+ this._conoutSocketWorker.dispose();
}
} else {
// Because pty.kill closes the handle, it will kill most processes by itself.
@@ -185,6 +193,11 @@
private _getConsoleProcessList(): Promise<number[]> {
return new Promise<number[]>(resolve => {
const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ]);
+ // Orca: an unhandled fork or IPC error would be rethrown process-wide.
+ agent.on('error', () => {
+ clearTimeout(timeout);
+ resolve([ this._innerPid ]);
+ });
agent.on('message', message => {
clearTimeout(timeout);
resolve(message.consoleProcessList);
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
--- a/src/windowsTerminal.ts
+++ b/src/windowsTerminal.ts
@@ -51,6 +51,29 @@
this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
this._socket = this._agent.outSocket;
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
+ let outputCloseEmitsExit = false;
+ let killedBeforeReady = false;
+ this._socket.on('error', () => {
+ // Orca: retire only this terminal. Rethrowing an unlisted code (write UNKNOWN, ECONNRESET)
+ // killed every PTY in the process; consumer listeners still receive the error.
+ this._close();
+ if (this._isReady || killedBeforeReady) {
+ return;
+ }
+ // Before the first output kill() stays deferred forever, so end the shell here.
+ killedBeforeReady = true;
+ try {
+ this._agent.kill();
+ } catch {
+ // The shell may already be gone; the terminal is retired either way.
+ }
+ // Before the relay connects no 'close' handler reports exit.
+ if (!outputCloseEmitsExit) {
+ this.emit('exit', this._agent.exitCode);
+ }
+ });
+
// Not available until `ready` event emitted.
this._pid = this._agent.innerPid;
this._fd = this._agent.fd;
@@ -59,6 +82,7 @@
// The forked windows terminal is not available until `ready` event is
// emitted.
this._socket.on('ready_datapipe', () => {
+ outputCloseEmitsExit = true;
// Run deferreds and set ready state once the first data event is received.
this._socket.once('data', () => {
@@ -82,25 +106,6 @@
}
});
- // Shutdown if `error` event is emitted.
- this._socket.on('error', err => {
- // Close terminal session.
- this._close();
-
- // EIO, happens when someone closes our child process: the only process
- // in the terminal.
- // node < 0.6.14: errno 5
- // node >= 0.6.14: read EIO
- if ((<any>err).code) {
- if (~(<any>err).code.indexOf('errno 5') || ~(<any>err).code.indexOf('EIO')) return;
- }
-
- // Throw anything else.
- if (this.listeners('error').length < 2) {
- throw err;
- }
- });
-
// Cleanup after the socket is closed.
this._socket.on('close', () => {
this.emit('exit', this._agent.exitCode);
@@ -114,6 +119,19 @@
this._readable = true;
this._writable = true;
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
+ this._agent.inSocket.on('error', () => {
+ if (!this._writable) {
+ return;
+ }
+ this._close();
+ try {
+ this._agent.kill();
+ } catch {
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
+ }
+ });
this._forwardEvents();
}
diff --git a/src/worker/conoutSocketWorker.ts b/src/worker/conoutSocketWorker.ts
--- a/src/worker/conoutSocketWorker.ts
+++ b/src/worker/conoutSocketWorker.ts
@@ -10,10 +10,33 @@
const conoutSocket = new Socket();
conoutSocket.setEncoding('utf8');
+// Orca: an unhandled stream error crashes this worker, and Node rethrows it on the main thread as
+// an uncaughtException that kills every PTY in the process (write EPIPE/UNKNOWN once the reader
+// closed: #26877, #33350). Contain errors per terminal and keep draining ConPTY output.
+const workerSockets = new Set<Socket>();
+const drainConout = (): void => {
+ conoutSocket.resume();
+};
+conoutSocket.on('error', () => {
+ // Closing the reader ends this terminal instead of leaving it waiting for output.
+ workerSockets.forEach(workerSocket => workerSocket.destroy());
+});
conoutSocket.connect(conoutPipeName, () => {
const server = createServer(workerSocket => {
+ if (conoutSocket.destroyed) {
+ workerSocket.destroy();
+ return;
+ }
+ workerSockets.add(workerSocket);
+ workerSocket.on('error', () => {});
conoutSocket.pipe(workerSocket);
+ // After pipe() so its unpipe-and-pause runs first; an undrained conout can block ClosePseudoConsole.
+ workerSocket.on('close', () => {
+ workerSockets.delete(workerSocket);
+ drainConout();
+ });
});
+ server.on('error', drainConout);
server.listen(getWorkerPipeName(conoutPipeName));
if (!parentPort) {
throw new Error('worker_threads parentPort is null');