mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix: retain MSYS shell descendants in their terminal job * test: complete MSYS regression CI registration and teardown contract * fix(windows): deny job breakaway for the whole Cygwin/MSYS shell family The per-PTY job probed only msys-2.0.dll, and only for bash.exe/sh.exe. Cygwin ships the same spawn.cc breakaway logic under cygwin1.dll, and an MSYS2 zsh escapes exactly like its bash does, so both kept the orphan bug. Probe the runtime DLL on the shell's own search path instead of matching shell names: that is the property that decides whether the runtime will ask for CREATE_BREAKAWAY_FROM_JOB, and it drops the name special-casing. * chore(patch): restore the conpty.cc index line The earlier hand-edit dropped it while every sibling section kept one. Recomputed against the real blobs: applying this patch to 7b286d3d yields exactly 4b06d185, so git apply -3 has its fallback back.
1275 lines
50 KiB
Diff
1275 lines
50 KiB
Diff
diff --git a/binding.gyp b/binding.gyp
|
|
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 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 +39,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 +85,16 @@
|
|
'libraries!': [
|
|
'-lutil'
|
|
]
|
|
+ }],
|
|
+ # 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..d838d795ecb9ea72e3bcc31113344947c006af7e 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;
|
|
@@ -287,8 +301,15 @@ var CustomWriteStream = /** @class */ (function () {
|
|
CustomWriteStream.prototype.dispose = function () {
|
|
clearImmediate(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 +325,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];
|
|
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..2ae787c5bd4f3eba470584dc658a01a52c690e0a 100644
|
|
--- a/src/unix/pty.cc
|
|
+++ b/src/unix/pty.cc
|
|
@@ -23,7 +23,9 @@
|
|
#include <errno.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
+#include <stdio.h>
|
|
#include <unistd.h>
|
|
+#include <string>
|
|
#include <thread>
|
|
|
|
#include <sys/types.h>
|
|
@@ -47,6 +49,25 @@
|
|
#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);
|
|
+# endif
|
|
+#endif
|
|
+
|
|
/* Some platforms name VWERASE and VDISCARD differently */
|
|
#if !defined(VWERASE) && defined(VWERSE)
|
|
#define VWERASE VWERSE
|
|
@@ -228,6 +249,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 +261,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 {
|
|
@@ -367,14 +401,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 +483,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 +627,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 +742,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,80 +822,118 @@ 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
|
|
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
|
|
index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c6678cf86 100644
|
|
--- a/src/win/conpty.cc
|
|
+++ b/src/win/conpty.cc
|
|
@@ -18,6 +18,7 @@
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <thread>
|
|
+#include <mutex>
|
|
#include <vector>
|
|
#include <Windows.h>
|
|
#include <strsafe.h>
|
|
@@ -44,12 +45,40 @@ 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: guards the job accessors below, and PtyKill, against the exit watcher
|
|
+// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still
|
|
+// read it unlocked, as they always have -- but it closes the window this patch
|
|
+// opened, where the watcher can close hShell/hJob and free the baton between a
|
|
+// lookup and its use.
|
|
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
|
|
+// shell-pid check against an unrelated process and terminate the wrong job.
|
|
+static std::mutex ptyJobMutex;
|
|
static volatile LONG ptyCounter;
|
|
|
|
static pty_baton* get_pty_baton(int id) {
|
|
@@ -102,8 +131,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(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 +294,20 @@
|
|
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);
|
|
@@ -303,6 +369,7 @@
|
|
marshal.Set("pty", Napi::Number::New(env, ptyId));
|
|
ptyHandles.emplace_back(
|
|
std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
|
|
+ ptyHandles.back()->allowJobBreakaway = !usesCygwinRuntime(shellpath);
|
|
} else {
|
|
throw Napi::Error::New(env, "Cannot launch conpty");
|
|
}
|
|
@@ -409,6 +476,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 +492,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 +505,48 @@ 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;
|
|
+ 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(
|
|
@@ -440,6 +559,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
|
|
|
// Update handle
|
|
handle->hShell = piClient.hProcess;
|
|
+ handle->shellPid = piClient.dwProcessId;
|
|
+ handle->hJob = hJob;
|
|
|
|
// Close the thread handle to avoid resource leak
|
|
CloseHandle(piClient.hThread);
|
|
@@ -544,27 +665,213 @@ 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);
|
|
-
|
|
- if (handle != nullptr) {
|
|
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
|
|
- bool fLoadedDll = hLibrary != nullptr;
|
|
- if (fLoadedDll)
|
|
+ // 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");
|
|
+ }
|
|
+
|
|
+ // 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(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(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 const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
|
|
- (HMODULE)hLibrary,
|
|
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
|
|
- if (pfnClosePseudoConsole)
|
|
- {
|
|
- pfnClosePseudoConsole(handle->hpc);
|
|
- }
|
|
- }
|
|
- if (useConptyDll) {
|
|
- TerminateProcess(handle->hShell, 1);
|
|
+ pfnClosePseudoConsole(hpc);
|
|
+ }
|
|
+ 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(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(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 PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) {
|
|
+ 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;
|
|
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
|
|
+ if (hHostJob != nullptr) {
|
|
+ return Napi::Boolean::New(env, true);
|
|
+ }
|
|
+
|
|
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
|
|
+ if (job == nullptr) {
|
|
+ return Napi::Boolean::New(env, false);
|
|
+ }
|
|
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
|
+ // BREAKAWAY_OK for the same reason as the per-pty job: without it a child
|
|
+ // asking for CREATE_BREAKAWAY_FROM_JOB is refused outright.
|
|
+ limits.BasicLimitInformation.LimitFlags =
|
|
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
|
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
|
|
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
|
|
+ // An outer job that forbids nesting refuses this; the caller degrades.
|
|
+ CloseHandle(job);
|
|
+ return Napi::Boolean::New(env, false);
|
|
+ }
|
|
+ hHostJob = job;
|
|
+ return Napi::Boolean::New(env, true);
|
|
}
|
|
|
|
/**
|
|
@@ -577,6 +884,9 @@ 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));
|
|
return exports;
|
|
};
|
|
|
|
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
|
|
index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7ed2d87ad 100644
|
|
--- a/lib/windowsPtyAgent.js
|
|
+++ b/lib/windowsPtyAgent.js
|
|
@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () {
|
|
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 +157,10 @@ var WindowsPtyAgent = /** @class */ (function () {
|
|
// 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 {
|
|
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
|
|
index 3c38f89..e20b3e6 100644
|
|
--- a/lib/windowsTerminal.js
|
|
+++ b/lib/windowsTerminal.js
|
|
@@ -50,6 +50,27 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
|
// 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.
|
|
+ _this._socket.on('error', function (err) {
|
|
+ var code = err && err.code;
|
|
+ // PTY output can report EPIPE before `_close()` wins the race.
|
|
+ _this._close();
|
|
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
|
|
+ return;
|
|
+ }
|
|
+ // 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 (typeof code === 'string') {
|
|
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))
|
|
+ return;
|
|
+ }
|
|
+ // Throw anything else.
|
|
+ if (_this.listeners('error').length < 2) {
|
|
+ throw err;
|
|
+ }
|
|
+ });
|
|
// Not available until `ready` event emitted.
|
|
_this._pid = _this._agent.innerPid;
|
|
_this._fd = _this._agent.fd;
|
|
@@ -76,23 +99,6 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
|
_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 +109,20 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
|
_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 +216,4 @@ var WindowsTerminal = /** @class */ (function (_super) {
|
|
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/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
|
|
index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e67bb1ad6 100644
|
|
--- a/src/windowsPtyAgent.ts
|
|
+++ b/src/windowsPtyAgent.ts
|
|
@@ -143,6 +143,9 @@ export class WindowsPtyAgent {
|
|
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 +162,10 @@ export class WindowsPtyAgent {
|
|
// 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.
|
|
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
|
|
index 13f6c6d..eda63c8 100644
|
|
--- a/src/windowsTerminal.ts
|
|
+++ b/src/windowsTerminal.ts
|
|
@@ -51,6 +51,30 @@ export class WindowsTerminal extends Terminal {
|
|
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.
|
|
+ this._socket.on('error', err => {
|
|
+ const code = (<any>err).code;
|
|
+
|
|
+ // PTY output can report EPIPE before `_close()` wins the race.
|
|
+ this._close();
|
|
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ // 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 (typeof code === 'string') {
|
|
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO')) return;
|
|
+ }
|
|
+
|
|
+ // Throw anything else.
|
|
+ if (this.listeners('error').length < 2) {
|
|
+ throw err;
|
|
+ }
|
|
+ });
|
|
+
|
|
// Not available until `ready` event emitted.
|
|
this._pid = this._agent.innerPid;
|
|
this._fd = this._agent.fd;
|
|
@@ -82,25 +108,6 @@ export class WindowsTerminal extends Terminal {
|
|
}
|
|
});
|
|
-
|
|
+
|
|
- // 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 +121,19 @@ export class WindowsTerminal extends Terminal {
|
|
-
|
|
+
|
|
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();
|
|
}
|