mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Teardown used to answer 'is this tree mine, and how do I kill it?' by scraping the process table, walking parent pids back to Orca, and running taskkill /T /F only if the walk said yes. Every step is a guess, and the code said so itself: windows-pty-root-identity.ts:35 already named the fix -- 'an inherited handle / Job Object'. The guesses fail in the ways users report. A pid walk cannot survive pid reuse, so teardown refused whenever it could not prove ownership, and a refused kill is an orphaned agent tree holding the worktree directory open (#9045, #10475, #10087). A descendant that reparented is invisible to the walk. The scrape itself could be blocked by policy, which read as 'no evidence'. node-pty now creates a job object per ConPTY and assigns the shell under CREATE_SUSPENDED, before it can spawn anything -- assigning afterwards leaves a window in which a fast child escapes. Termination is one TerminateJobObject; liveness is QueryInformationJobObject. Verified on Windows 11 against a shell whose grandchild was spawned detached: job membership came back [shell, grandchild] and one call killed both. Neither a parent-pid walk nor GetConsoleProcessList sees that grandchild -- it leaves the console and reparents, which is exactly the claude.exe/node.exe/cmd.exe orphan in #9045. KILL_ON_JOB_CLOSE means a daemon that dies without unwinding no longer strands shells (#9195, #10415). The job is the daemon's, not the app's, so an app-main crash still leaves sessions alive -- the guarantee win-crash-survival-e2e asserts. Both entry points report unavailable rather than a false success when a pty has no job: an outer job without BREAKAWAY_OK can refuse the assignment, and a pty from an older build has none. Reading 'we could not tell' as 'already dead' is the original bug, so the old probe stays as the fallback.
588 lines
21 KiB
Diff
588 lines
21 KiB
Diff
diff --git a/binding.gyp b/binding.gyp
|
|
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
|
--- a/binding.gyp
|
|
+++ b/binding.gyp
|
|
@@ -5,9 +5,6 @@
|
|
],
|
|
'conditions': [
|
|
['OS=="win"', {
|
|
- 'msvs_configuration_attributes': {
|
|
- 'SpectreMitigation': 'Spectre'
|
|
- },
|
|
'msvs_settings': {
|
|
'VCCLCompilerTool': {
|
|
'AdditionalOptions': [
|
|
@@ -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/unixTerminal.js b/lib/unixTerminal.js
|
|
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 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;
|
|
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..383df0c9c48355547c65e6c9bbba593d15c4dd44 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
|
|
@@ -237,13 +258,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,10 +398,11 @@ 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.");
|
|
@@ -684,15 +716,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 +796,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..4ae9ddec6ac3e9fdaa380000d3017bbb394f22d6 100644
|
|
--- a/src/win/conpty.cc
|
|
+++ b/src/win/conpty.cc
|
|
@@ -46,6 +46,11 @@ struct pty_baton {
|
|
|
|
HANDLE hShell;
|
|
|
|
+ // 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;
|
|
+
|
|
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
|
|
};
|
|
|
|
@@ -103,6 +108,13 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
|
|
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
|
|
// Clean up handles
|
|
CloseHandle(baton->hShell);
|
|
+ // Orca: closing the last job handle is what reaps whatever the shell left
|
|
+ // behind. Without this the job outlives the pane and its descendants keep
|
|
+ // running (and keep holding the worktree directory open).
|
|
+ if (baton->hJob != nullptr) {
|
|
+ CloseHandle(baton->hJob);
|
|
+ baton->hJob = nullptr;
|
|
+ }
|
|
assert(remove_pty_baton(baton->id));
|
|
|
|
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
|
|
@@ -416,7 +428,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,6 +441,31 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
|
|
throw errorWithCode(info, "Cannot create process");
|
|
}
|
|
|
|
+ // 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.
|
|
+ //
|
|
+ // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE means the tree also dies if this host
|
|
+ // process dies without unwinding, so a crashed daemon cannot strand shells.
|
|
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
|
|
+ if (hJob != nullptr) {
|
|
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
|
|
+ jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
+ 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;
|
|
+ }
|
|
+ }
|
|
+ handle->hJob = hJob;
|
|
+
|
|
+ // Safe to run now: either it is in the job, or we accepted that it is not.
|
|
+ ResumeThread(piClient.hThread);
|
|
+
|
|
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
|
|
bool fLoadedDll = hLibrary != nullptr;
|
|
if (useConptyDll && fLoadedDll)
|
|
@@ -567,6 +607,72 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
|
|
return env.Undefined();
|
|
}
|
|
|
|
+/**
|
|
+ * 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() != 1 || !info[0].IsNumber()) {
|
|
+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id)");
|
|
+ }
|
|
+
|
|
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
|
|
+ if (handle == nullptr || handle->hJob == nullptr) {
|
|
+ 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.
|
|
+ *
|
|
+ * This is the liveness answer a stale JS map cannot give: a pane whose shell
|
|
+ * has exited reports an empty list, so a registry entry can no longer claim a
|
|
+ * terminal is connected when nothing is running.
|
|
+ * Returns null when no job was assigned.
|
|
+ */
|
|
+static Napi::Value PtyListJobProcessIds(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.listJobProcessIds(id)");
|
|
+ }
|
|
+
|
|
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
|
|
+ if (handle == nullptr || handle->hJob == nullptr) {
|
|
+ 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());
|
|
+ list->NumberOfAssignedProcesses = capacity;
|
|
+ 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();
|
|
+}
|
|
+
|
|
/**
|
|
* Init
|
|
*/
|
|
@@ -577,6 +683,8 @@ 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));
|
|
return exports;
|
|
};
|
|
|