fix(windows): own agent PTYs with kill-on-close Jobs

This commit is contained in:
OrcaWin
2026-07-20 02:28:40 -07:00
parent e5a67d121b
commit dea7375dd5
20 changed files with 1452 additions and 289 deletions
+1
View File
@@ -9,3 +9,4 @@
/skill-guides/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
/config/patches/node-pty@1.1.0.patch text eol=lf whitespace=-blank-at-eol
+499 -43
View File
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..c73569f8bac0a79023d26123e10db8a074089cf1 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -18,7 +18,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
+++ 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")',
@@ -54,8 +54,29 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
'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..201fc1d70a907e31b5a2b33b19a9db20803bc652 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
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..3c4ae8e7818379a4f6f09ee740959292a2c3515f 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -73,31 +94,75 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,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
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
index a358ffb177357e177661033c1b092f9c9d0e5f5a..acc4f5ac94f38db2184ba7d3da382c720ca7807a 100644
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -26,10 +26,11 @@ var FLUSH_DATA_INTERVAL = 1000;
* and winpty.
*/
var WindowsPtyAgent = /** @class */ (function () {
- function WindowsPtyAgent(file, args, env, cwd, cols, rows, debug, _useConpty, _useConptyDll, conptyInheritCursor) {
+ function WindowsPtyAgent(file, args, env, cwd, cols, rows, debug, _useConpty, _useConptyDll, conptyInheritCursor, windowsAgentJob) {
var _this = this;
if (_useConptyDll === void 0) { _useConptyDll = false; }
if (conptyInheritCursor === void 0) { conptyInheritCursor = false; }
+ if (windowsAgentJob === void 0) { windowsAgentJob = false; }
this._useConpty = _useConpty;
this._useConptyDll = _useConptyDll;
this._pid = 0;
@@ -86,7 +87,7 @@ var WindowsPtyAgent = /** @class */ (function () {
});
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
- var connect = this._ptyNative.connect(this._pty, commandLine, cwd, env, this._useConptyDll, function (c) { return _this._$onProcessExit(c); });
+ var connect = this._ptyNative.connect(this._pty, commandLine, cwd, env, this._useConptyDll, windowsAgentJob, function (c) { return _this._$onProcessExit(c); });
this._innerPid = connect.pid;
}
}
@@ -130,6 +131,13 @@ var WindowsPtyAgent = /** @class */ (function () {
this._ptyNative.clear(this._pty, this._useConptyDll);
}
};
+ WindowsPtyAgent.prototype.terminateJobTree = function (timeoutMs) {
+ if (!this._useConpty) {
+ return undefined;
+ }
+ var _a;
+ return (_a = this._ptyNative.terminateJobTree) === null || _a === void 0 ? void 0 : _a.call(this._ptyNative, this._pty, timeoutMs);
+ };
WindowsPtyAgent.prototype.kill = function () {
var _this = this;
// Tell the agent to kill the pty, this releases handles to the process
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
index 3c38f89d14cece8081782a9600ebc582b7c2a62a..5790166246b1cbe6d33e0563b6498901dd80b1a9 100644
--- a/lib/windowsTerminal.js
+++ b/lib/windowsTerminal.js
@@ -48,7 +48,7 @@ var WindowsTerminal = /** @class */ (function (_super) {
// Functions that need to run after `ready` event is emitted.
_this._deferreds = [];
// 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._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor, opt.windowsAgentJob);
_this._socket = _this._agent.outSocket;
// Not available until `ready` event emitted.
_this._pid = _this._agent.innerPid;
@@ -144,6 +144,9 @@ var WindowsTerminal = /** @class */ (function (_super) {
_this.kill();
});
};
+ WindowsTerminal.prototype.terminateJobTree = function (timeoutMs) {
+ return this._agent.terminateJobTree(timeoutMs);
+ };
WindowsTerminal.prototype.kill = function (signal) {
var _this = this;
this._deferNoArgs(function () {
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..24519545c34e0f9a4438a814dd1feb9fd18517d1 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
@@ -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);
@@ -110,8 +175,38 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
+}
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/interfaces.ts b/src/interfaces.ts
index a269e77bd2acf31c762b0fd8e13e6bf435fd7d07..adc2e8d10190de6bbeea1f2b53320ef92e0b2935 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -121,6 +121,8 @@ export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean;
useConptyDll?: boolean;
conptyInheritCursor?: boolean;
+ /** Orca-specific: own this ConPTY and all descendants in a kill-on-close Job. */
+ windowsAgentJob?: boolean;
}
export interface IPtyOpenOptions {
diff --git a/src/native.d.ts b/src/native.d.ts
index c53e086b4968d6fb0bf601ace3a813809433fedb..7d7ae975dabe17e5b9dadc3c8d09364fc438c421 100644
--- a/src/native.d.ts
+++ b/src/native.d.ts
@@ -4,10 +4,11 @@
interface IConptyNative {
startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, conptyInheritCursor: boolean, useConptyDll: boolean): IConptyProcess;
- connect(ptyId: number, commandLine: string, cwd: string, env: string[], useConptyDll: boolean, onExitCallback: (exitCode: number) => void): { pid: number };
+ connect(ptyId: number, commandLine: string, cwd: string, env: string[], useConptyDll: boolean, windowsAgentJob: boolean, onExitCallback: (exitCode: number) => void): { pid: number };
resize(ptyId: number, cols: number, rows: number, useConptyDll: boolean): void;
clear(ptyId: number, useConptyDll: boolean): void;
kill(ptyId: number, useConptyDll: boolean): void;
+ terminateJobTree?(ptyId: number, timeoutMs: number): Promise<boolean> | undefined;
}
interface IWinptyNative {
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..5c28e6465a6fd506cbc6b548b6d9cb918d3be0da 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -122,11 +217,11 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#include <unistd.h>
+#include <string>
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
@@ -147,12 +242,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -367,10 +379,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) {
@@ -167,7 +262,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
@@ -238,7 +333,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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 +777,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -255,7 +350,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
if (res == -1) {
@@ -263,7 +358,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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];
@@ -273,14 +368,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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) {
@@ -289,7 +384,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
@@ -298,7 +393,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
@@ -312,7 +407,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
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);
@@ -328,7 +423,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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);
@@ -339,7 +434,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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);
@@ -349,7 +444,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ 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);
@@ -374,7 +469,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
@@ -384,3 +479,364 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..dc8dd58bf6138d3b3cc2bede3efb6fdf7dbeb28f 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -45,6 +45,7 @@ struct pty_baton {
HPCON hpc;
HANDLE hShell;
+ HANDLE hJob = nullptr;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
@@ -91,7 +92,18 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
delete th;
});
*th = std::thread([tsfn = std::move(tsfn), baton] {
- auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
+ auto callback = [baton](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
+ if (baton->hShell != nullptr) {
+ GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
+ CloseHandle(baton->hShell);
+ baton->hShell = nullptr;
+ }
+ HANDLE hJob = baton->hJob;
+ baton->hJob = nullptr;
+ assert(remove_pty_baton(baton->id));
+ if (hJob != nullptr) {
+ CloseHandle(hJob);
+ }
cb.Call({Napi::Number::New(env, exit_event->exit_code)});
delete exit_event;
};
@@ -99,15 +111,41 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
ExitEvent *exit_event = new ExitEvent;
// Wait for process to complete.
WaitForSingleObject(baton->hShell, INFINITE);
- // Get process exit code.
- GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
- // Clean up handles
- CloseHandle(baton->hShell);
- assert(remove_pty_baton(baton->id));
+
+ if (baton->hJob != nullptr) {
+ // Why: an agent root can exit before its detached tools. Do not publish
+ // PTY exit until Windows proves every member has released its handles.
+ TerminateJobObject(baton->hJob, 1);
+ while (true) {
+ JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting{};
+ if (QueryInformationJobObject(
+ baton->hJob,
+ JobObjectBasicAccountingInformation,
+ &accounting,
+ sizeof(accounting),
+ nullptr) && accounting.ActiveProcesses == 0) {
+ break;
+ }
+ // A permanent query/termination failure deliberately keeps the PTY
+ // owned and un-reaped so destructive callers cannot race deletion.
+ Sleep(25);
+ }
+ }
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
case napi_closing:
+ // The environment no longer accepts JS calls, so no native method can
+ // race these per-baton handles during finalization.
+ if (baton->hShell != nullptr) {
+ CloseHandle(baton->hShell);
+ baton->hShell = nullptr;
+ }
+ if (baton->hJob != nullptr) {
+ CloseHandle(baton->hJob);
+ baton->hJob = nullptr;
+ }
+ delete exit_event;
break;
case napi_queue_full:
@@ -333,14 +371,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
std::stringstream errorText;
BOOL fSuccess = FALSE;
- if (info.Length() != 6 ||
+ if (info.Length() != 7 ||
!info[0].IsNumber() ||
!info[1].IsString() ||
!info[2].IsString() ||
!info[3].IsArray() ||
!info[4].IsBoolean() ||
- !info[5].IsFunction()) {
- throw Napi::Error::New(env, "Usage: pty.connect(id, cmdline, cwd, env, useConptyDll, exitCallback)");
+ !info[5].IsBoolean() ||
+ !info[6].IsFunction()) {
+ throw Napi::Error::New(env, "Usage: pty.connect(id, cmdline, cwd, env, useConptyDll, windowsAgentJob, exitCallback)");
}
const int id = info[0].As<Napi::Number>().Int32Value();
@@ -348,7 +387,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
const std::wstring cwd(path_util::to_wstring(info[2].As<Napi::String>()));
const Napi::Array envValues = info[3].As<Napi::Array>();
const bool useConptyDll = info[4].As<Napi::Boolean>().Value();
- Napi::Function exitCallback = info[5].As<Napi::Function>();
+ const bool windowsAgentJob = info[5].As<Napi::Boolean>().Value();
+ Napi::Function exitCallback = info[6].As<Napi::Function>();
// Fetch pty handle from ID and start process
pty_baton* handle = get_pty_baton(id);
@@ -416,7 +456,8 @@ 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
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT |
+ (windowsAgentJob ? CREATE_SUSPENDED : 0), // dwCreationFlags
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,6 +467,39 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
+ if (windowsAgentJob) {
+ // Job setup is atomic while the shell is suspended; partial ownership is unsafe.
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ if (hJob == nullptr ||
+ !SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
+ const DWORD jobError = GetLastError();
+ TerminateProcess(piClient.hProcess, 1);
+ WaitForSingleObject(piClient.hProcess, INFINITE);
+ CloseHandle(piClient.hThread); CloseHandle(piClient.hProcess);
+ if (hJob != nullptr) CloseHandle(hJob);
+ SetLastError(jobError);
+ throw errorWithCode(info, "Cannot assign process to Job");
+ }
+ handle->hJob = hJob;
+
+ if (ResumeThread(piClient.hThread) == static_cast<DWORD>(-1)) {
+ const DWORD resumeError = GetLastError();
+ TerminateProcess(piClient.hProcess, 1);
+ WaitForSingleObject(piClient.hProcess, INFINITE);
+ CloseHandle(piClient.hThread);
+ CloseHandle(piClient.hProcess);
+ if (handle->hJob != nullptr) {
+ CloseHandle(handle->hJob);
+ handle->hJob = nullptr;
+ }
+ SetLastError(resumeError);
+ throw errorWithCode(info, "Cannot resume process");
+ }
+ }
+
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (useConptyDll && fLoadedDll)
@@ -567,6 +641,109 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
return env.Undefined();
}
+struct JobTerminationResult {
+ bool drained = false;
+};
+
+static Napi::Value PtyTerminateJobTree(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.terminateJobTree(id, timeoutMs)");
+ }
+
+ const int id = info[0].As<Napi::Number>().Int32Value();
+ const pty_baton* handle = get_pty_baton(id);
+ if (handle == nullptr || handle->hJob == nullptr) {
+ return env.Undefined();
+ }
+
+ const double timeoutValue = info[1].As<Napi::Number>().DoubleValue();
+ if (timeoutValue < 1 || timeoutValue > 60000) {
+ throw Napi::RangeError::New(env, "timeoutMs must be between 1 and 60000");
+ }
+
+ HANDLE hJob = nullptr;
+ if (!DuplicateHandle(
+ GetCurrentProcess(),
+ handle->hJob,
+ GetCurrentProcess(),
+ &hJob,
+ 0,
+ FALSE,
+ DUPLICATE_SAME_ACCESS)) {
+ return env.Undefined();
+ }
+ if (!TerminateJobObject(hJob, 1)) {
+ CloseHandle(hJob);
+ return env.Undefined();
+ }
+
+ // Why: termination is asynchronous. Keep a duplicate Job handle until the
+ // OS reports no members instead of treating the root process exit as proof.
+ auto deferred = Napi::Promise::Deferred::New(env);
+ std::thread *th = new std::thread;
+ auto tsfn = Napi::ThreadSafeFunction::New(
+ env,
+ Napi::Function::New(env, [](const Napi::CallbackInfo&) {}),
+ "PtyTerminateJobTree_resource",
+ 0,
+ 1,
+ [th](Napi::Env) {
+ th->join();
+ delete th;
+ });
+ const DWORD timeoutMs = static_cast<DWORD>(timeoutValue);
+ *th = std::thread([tsfn = std::move(tsfn), deferred, hJob, timeoutMs] {
+ auto *result = new JobTerminationResult;
+ const ULONGLONG startedAt = GetTickCount64();
+ while (true) {
+ JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting{};
+ if (!QueryInformationJobObject(
+ hJob,
+ JobObjectBasicAccountingInformation,
+ &accounting,
+ sizeof(accounting),
+ nullptr)) {
+ break;
+ }
+ if (accounting.ActiveProcesses == 0) {
+ result->drained = true;
+ break;
+ }
+ if (GetTickCount64() - startedAt >= timeoutMs) {
+ break;
+ }
+ Sleep(25);
+ }
+ CloseHandle(hJob);
+
+ const auto status = tsfn.BlockingCall(
+ result,
+ [deferred](Napi::Env env, Napi::Function, JobTerminationResult *result) {
+ deferred.Resolve(Napi::Boolean::New(env, result->drained));
+ delete result;
+ });
+ switch (status) {
+ case napi_closing:
+ delete result;
+ break;
+ case napi_queue_full:
+ Napi::Error::Fatal("PtyTerminateJobTree", "Queue was full");
+ case napi_ok:
+ if (tsfn.Release() != napi_ok) {
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.Release() failed");
+ }
+ break;
+ default:
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.BlockingCall() failed");
+ }
+ });
+
+ return deferred.Promise();
+}
+
/**
* Init
*/
@@ -577,6 +754,7 @@ 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("terminateJobTree", Napi::Function::New(env, PtyTerminateJobTree));
return exports;
};
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
index d7054449516f0c9a62af351c2caa17331206d530..43e44678826d5883691931875a91cdf7ae141dd0 100644
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -56,7 +56,8 @@ export class WindowsPtyAgent {
debug: boolean,
private _useConpty: boolean | undefined,
private _useConptyDll: boolean = false,
- conptyInheritCursor: boolean = false
+ conptyInheritCursor: boolean = false,
+ windowsAgentJob: boolean = false
) {
if (this._useConpty === undefined || this._useConpty === true) {
this._useConpty = this._getWindowsBuildNumber() >= 18309;
@@ -116,7 +117,7 @@ export class WindowsPtyAgent {
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
- const connect = (this._ptyNative as IConptyNative).connect(this._pty, commandLine, cwd, env, this._useConptyDll, c => this._$onProcessExit(c));
+ const connect = (this._ptyNative as IConptyNative).connect(this._pty, commandLine, cwd, env, this._useConptyDll, windowsAgentJob, c => this._$onProcessExit(c));
this._innerPid = connect.pid;
}
}
@@ -138,6 +139,13 @@ export class WindowsPtyAgent {
}
}
+ public terminateJobTree(timeoutMs: number): Promise<boolean> | undefined {
+ if (!this._useConpty) {
+ return undefined;
+ }
+ return (this._ptyNative as IConptyNative).terminateJobTree?.(this._pty, timeoutMs);
+ }
+
public kill(): void {
// Tell the agent to kill the pty, this releases handles to the process
if (this._useConpty) {
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
index 13f6c6dbb3d7965c4699568f4704be3b6a33d947..a1189c0fd9d2bc4d9cb31271c9924b60acc04585 100644
--- a/src/windowsTerminal.ts
+++ b/src/windowsTerminal.ts
@@ -48,7 +48,7 @@ export class WindowsTerminal extends Terminal {
this._deferreds = [];
// Create new termal.
- this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
+ this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor, opt.windowsAgentJob);
this._socket = this._agent.outSocket;
// Not available until `ready` event emitted.
@@ -161,6 +161,10 @@ export class WindowsTerminal extends Terminal {
});
}
+ public terminateJobTree(timeoutMs: number): Promise<boolean> | undefined {
+ return this._agent.terminateJobTree(timeoutMs);
+ }
+
public kill(signal?: string): void {
this._deferNoArgs(() => {
if (signal) {
diff --git a/typings/node-pty.d.ts b/typings/node-pty.d.ts
index 6f050ff1d97b8d3477be7ee95d2af516bd9517b7..4387cafd16596dd0f4674549ff2deecb884f30a7 100644
--- a/typings/node-pty.d.ts
+++ b/typings/node-pty.d.ts
@@ -108,6 +108,9 @@ declare module 'node-pty' {
* @see https://docs.microsoft.com/en-us/windows/console/createpseudoconsole
*/
conptyInheritCursor?: boolean;
+
+ /** Orca-specific: own this ConPTY and all descendants in a kill-on-close Job. */
+ windowsAgentJob?: boolean;
}
/**
@@ -183,6 +186,9 @@ declare module 'node-pty' {
*/
kill(signal?: string): void;
+ /** Terminates and waits for the Windows ConPTY Job Object to drain. */
+ terminateJobTree?(timeoutMs: number): Promise<boolean> | undefined;
+
/**
* Pauses the pty for customizable flow control.
*/
+20 -10
View File
@@ -64,7 +64,7 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
runPnpm(['rebuild', 'node-pty'])
runPnpm(['rebuild', 'node-pty'], { npm_config_build_from_source: 'true' })
verifyNodeRuntimeAfterRebuild()
return
}
@@ -74,7 +74,12 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
runPnpm(
['rebuild', ...failedModules],
failedModules.includes('node-pty') && requiresPatchedNodePtySourceBuild()
? { npm_config_build_from_source: 'true' }
: {}
)
verifyNodeRuntimeAfterRebuild()
}
@@ -272,6 +277,13 @@ function loadNodePtyNativeModule() {
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
)
}
if (
requiresPatchedNodePtySourceBuild() &&
nativeName === 'conpty' &&
typeof native.module.terminateJobTree !== 'function'
) {
throw new Error('patched node-pty conpty.node is missing Windows Job teardown methods')
}
}
function assertNodePtyWindowsConptyRuntime(nativeDir) {
@@ -300,10 +312,11 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
// Why: upstream prebuilds can load while omitting Orca's patched Unix spawn
// handling or Windows Job Object teardown surface.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
const nativeArtifact = process.platform === 'win32' ? 'conpty.node' : 'pty.node'
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', nativeArtifact)]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
@@ -318,10 +331,6 @@ function getPatchedNodePtyRebuildReason() {
}
function requiresPatchedNodePtySourceBuild() {
if (process.platform === 'win32') {
return false
}
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -339,10 +348,11 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
function runPnpm(args, extraEnv = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
cwd: projectDir,
env: { ...process.env, ...extraEnv },
stdio: 'inherit',
shell: process.platform === 'win32'
})
+105 -88
View File
@@ -49,108 +49,101 @@ describe('ensure-native-runtime', () => {
}
})
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
const projectDir = mkTempProject()
it('rebuilds patched node-pty artifacts even when the Node load check passes', () => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log).toContain('build_from_source=true\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
it.skipIf(process.platform === 'win32')(
'rebuilds when patched artifacts exist but node-pty resolves to prebuilds',
() => {
const projectDir = mkTempProject()
it('rebuilds when patched artifacts exist but node-pty resolves to prebuilds', () => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
it.skipIf(process.platform === 'win32')(
'keeps the fast path when the platform-specific patched artifacts exist',
() => {
const projectDir = mkTempProject()
it('keeps the fast path when the platform-specific patched artifacts exist', () => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
})
function mkTempProject() {
@@ -211,7 +204,12 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
return {
dir,
module: dir.replace(/\\\\/g, '/').includes('build/Release/')
? { terminateJobTree() {} }
: {}
}
}
`
)
@@ -238,7 +236,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
writeFileSync(join(buildDir, process.platform === 'win32' ? 'conpty.node' : 'pty.node'), '')
if (process.platform === 'win32') {
const runtimeDir = join(buildDir, 'conpty')
mkdirSync(runtimeDir, { recursive: true })
writeFileSync(join(runtimeDir, 'conpty.dll'), '')
writeFileSync(join(runtimeDir, 'OpenConsole.exe'), '')
}
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
@@ -250,10 +254,23 @@ function writeFakePnpm(binDir) {
writeFileSync(
shimPath,
`
const { appendFileSync, writeFileSync } = require('node:fs')
const { appendFileSync, mkdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`build_from_source=\${process.env.npm_config_build_from_source ?? ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
if (process.platform === 'win32') {
const buildDir = join(process.cwd(), 'node_modules', 'node-pty', 'build', 'Release')
const runtimeDir = join(buildDir, 'conpty')
mkdirSync(runtimeDir, { recursive: true })
writeFileSync(join(buildDir, 'conpty.node'), '')
writeFileSync(join(runtimeDir, 'conpty.dll'), '')
writeFileSync(join(runtimeDir, 'OpenConsole.exe'), '')
}
`
)
+14 -8
View File
@@ -393,12 +393,13 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
// Why: upstream prebuilds can load while omitting Orca's patched Unix spawn
// handling or Windows Job Object teardown surface.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
const nativeArtifact = rebuildPlatform === 'win32' ? 'conpty.node' : 'pty.node'
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', nativeArtifact)]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
if (rebuildPlatform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
@@ -414,9 +415,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
if (rebuildPlatform === 'win32') {
return false
}
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
@@ -468,7 +466,8 @@ function loadNativeModule(moduleName) {
if (moduleName === 'node-pty') {
projectRequire('node-pty')
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
const native = loadNativeModule(getNodePtyNativeModuleName())
const nativeName = getNodePtyNativeModuleName()
const native = loadNativeModule(nativeName)
assertNodePtyWindowsConptyRuntime(native.dir)
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
throw new Error(
@@ -477,6 +476,13 @@ function loadNativeModule(moduleName) {
'; expected build/Release so Orca\\'s node-pty patch is active'
)
}
if (
requirePatchedNodePtySourceBuild &&
nativeName === 'conpty' &&
typeof native.module.terminateJobTree !== 'function'
) {
throw new Error('patched node-pty conpty.node is missing Windows Job teardown methods')
}
return
}
projectRequire(moduleName)
+100 -88
View File
@@ -10,7 +10,7 @@ import {
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -179,94 +179,91 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
}
})
it.skipIf(process.platform === 'win32')(
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
() => {
const projectDir = mkTempProject()
it('rebuilds when Electron can load node-pty but patched build artifacts are missing', () => {
const projectDir = mkTempProject()
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir)
writeNodePtyPatchFile(projectDir)
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir)
writeNodePtyPatchFile(projectDir)
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain(
'Patched node-pty build artifacts are missing; rebuilding from source.'
)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain(
'Patched node-pty build artifacts are missing; rebuilding from source.'
)
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
expect(rebuildCall.ignoreModules).toEqual(['cpu-features'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(
process.platform === 'win32' ? ['node-pty', 'windows-native-registry'] : ['node-pty']
)
expect(rebuildCall.ignoreModules).toEqual(['cpu-features'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
it.skipIf(process.platform === 'win32')(
'keeps the Electron load-probe fast path once patched node-pty artifacts exist',
() => {
const projectDir = mkTempProject()
it('keeps the Electron load-probe fast path once patched node-pty artifacts exist', () => {
const projectDir = mkTempProject()
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
writeFakeWindowsRegistry(projectDir)
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain(
'Native modules already load in Electron; skipping rebuild.'
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Native modules already load in Electron; skipping rebuild.')
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
it.skipIf(process.platform === 'win32')(
'rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds',
() => {
const projectDir = mkTempProject()
it('rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds', () => {
const projectDir = mkTempProject()
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../prebuilds/darwin-arm64/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir)
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, {
nativeDir: `../prebuilds/${process.platform}-${process.arch}/`
})
writeFakeWindowsRegistry(projectDir)
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Native modules do not load in Electron; rebuilding.')
expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active")
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Native modules do not load in Electron; rebuilding.')
expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active")
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(
process.platform === 'win32' ? ['node-pty', 'windows-native-registry'] : ['node-pty']
)
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
)
})
})
function mkTempProject() {
@@ -283,8 +280,8 @@ function mkTempProject() {
function runRebuildScript(projectDir, extraEnv = {}, args = []) {
const env = {
...process.env,
npm_config_platform: 'linux',
npm_config_arch: 'x64'
npm_config_platform: process.platform,
npm_config_arch: process.arch
}
for (const key of Object.keys(env)) {
if (
@@ -415,20 +412,26 @@ export async function rebuild(options) {
)
}
function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) {
function writeFakeUsableElectronPackage(projectDir, { platform = process.platform } = {}) {
writeFakeElectronPackage(projectDir)
const electronDir = join(projectDir, 'node_modules', 'electron')
const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron'
const electronPath = join(electronDir, 'dist', platformExecutable)
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'path.txt'), platformExecutable)
const executablePath =
platform === 'darwin'
? 'Electron.app/Contents/MacOS/Electron'
: platform === 'win32'
? 'electron.exe'
: 'electron'
const electronPath = join(electronDir, 'dist', executablePath)
mkdirSync(dirname(electronPath), { recursive: true })
writeFileSync(join(electronDir, 'path.txt'), executablePath)
writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0')
if (platform === 'win32') {
copyFileSync(process.execPath, electronPath)
} else {
writeFileSync(
electronPath,
`#!/usr/bin/env node
return
}
writeFileSync(
electronPath,
`#!/usr/bin/env node
const { spawnSync } = require('node:child_process')
const result = spawnSync(process.execPath, process.argv.slice(2), {
@@ -444,9 +447,8 @@ if (result.error) {
process.exit(result.status ?? 0)
`
)
chmodSync(electronPath, 0o755)
}
)
chmodSync(electronPath, 0o755)
}
function writeFakeNodePtyConptyPayload(projectDir, arch) {
@@ -475,7 +477,13 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
join(nodePtyDir, 'lib', 'utils.js'),
`
exports.loadNativeModule = function loadNativeModule(nativeName) {
return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
const dir = ${JSON.stringify(nativeDir)}
return {
dir,
module: dir.replace(/\\\\/g, '/').includes('build/Release/')
? { terminateJobTree() {} }
: { nativeName }
}
}
`
)
@@ -496,6 +504,10 @@ function writeNodePtyPatchFile(projectDir) {
}
function writePatchedNodePtyBuildArtifacts(projectDir) {
if (process.platform === 'win32') {
writeFakeNodePtyConptyPayload(projectDir, process.arch)
return
}
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
+3 -3
View File
@@ -21,7 +21,7 @@ patchedDependencies:
hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
hash: 0b44048c9daec38eee7bca718b1de63f8bf08338c9acee12d257405a246a9161
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -63,7 +63,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
version: 1.1.0(patch_hash=0b44048c9daec38eee7bca718b1de63f8bf08338c9acee12d257405a246a9161)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12043,7 +12043,7 @@ snapshots:
undici: 6.27.0
which: 6.0.1
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
node-pty@1.1.0(patch_hash=0b44048c9daec38eee7bca718b1de63f8bf08338c9acee12d257405a246a9161):
dependencies:
node-addon-api: 7.1.1
+49
View File
@@ -101,6 +101,7 @@ function mockPtyProcess(pid = 12345) {
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
terminateJobTree: vi.fn(async () => true),
process: 'zsh',
onData: vi.fn((cb: (data: string) => void) => {
onDataListeners.push(cb)
@@ -196,6 +197,54 @@ describe('createPtySubprocess', () => {
)
})
it('requests and exposes spawn-time Windows Job teardown for an agent ConPTY', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const handle = createPtySubprocess({
sessionId: 'windows-agent-job',
cols: 80,
rows: 24,
launchAgent: 'claude',
env: {}
})
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ windowsAgentJob: true })
)
await expect(handle.terminateJobTree?.(1234)).resolves.toBe(true)
expect(proc.terminateJobTree).toHaveBeenCalledWith(1234)
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not request a Windows Job for a plain ConPTY', () => {
spawnMock.mockReturnValue(mockPtyProcess())
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
createPtySubprocess({ sessionId: 'windows-plain', cols: 80, rows: 24, env: {} })
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.not.objectContaining({ windowsAgentJob: true })
)
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('appends Git prompt guards after the detached daemon inherited config', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+23 -2
View File
@@ -496,6 +496,7 @@ function spawnDaemonPtyWithWindowsFallback(args: {
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
windowsAgentJob: boolean
}): {
process: pty.IPty
shellPath: string
@@ -512,7 +513,8 @@ function spawnDaemonPtyWithWindowsFallback(args: {
env: args.env,
// Why: bundled ConPTY has the modern wrap-marker behavior xterm expects;
// legacy system ConPTY can corrupt full-width TUI rows in scrollback.
...(process.platform === 'win32' ? { useConptyDll: true } : {})
...(process.platform === 'win32' ? { useConptyDll: true } : {}),
...(args.windowsAgentJob ? { windowsAgentJob: true } : {})
})
}
@@ -821,7 +823,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
env,
cols: size.cols,
rows: size.rows,
windowsFallbackAttempts
windowsFallbackAttempts,
// Why: WSL owns a Linux process tree, while only explicit native agent
// sessions should change Windows process-launch semantics.
windowsAgentJob:
process.platform === 'win32' &&
pathWin32.basename(shellPath).toLowerCase() !== 'wsl.exe' &&
Boolean(opts.launchAgent)
})
proc = spawned.process
// Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded
@@ -1044,9 +1052,22 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
})
const nativeWindowsPty =
process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() !== 'wsl.exe'
const windowsJobControl = proc as pty.IPty & {
terminateJobTree?(timeoutMs: number): Promise<boolean> | undefined
}
return {
pid: proc.pid,
shellPath,
...(nativeWindowsPty
? {
nativeWindowsPty: true,
terminateJobTree: (timeoutMs: number) =>
windowsJobControl.terminateJobTree?.call(proc, timeoutMs)
}
: {}),
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
// Why: node-pty's `.process` getter reports the PTY's live foreground
+12
View File
@@ -50,6 +50,10 @@ export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000
export type SubprocessHandle = {
pid: number
/** True only when pid belongs to this Windows host, never wsl.exe/remote. */
nativeWindowsPty?: boolean
/** Terminates the spawn-time Job and resolves true only after every member exits. */
terminateJobTree?(timeoutMs: number): Promise<boolean> | undefined
/** Live foreground process name of the PTY (node-pty's `.process`), e.g.
* 'claude' / 'codex' / 'zsh'. Null once the child has exited. */
getForegroundProcess(): string | null
@@ -227,6 +231,14 @@ export class Session {
return this.subprocess.pid
}
get ownsNativeWindowsPty(): boolean {
return this.subprocess.nativeWindowsPty === true
}
terminateJobTree(timeoutMs: number): Promise<boolean> | undefined {
return this.subprocess.terminateJobTree?.(timeoutMs)
}
write(data: string): void {
if (this._state === 'exited' || this._disposed) {
return
@@ -1,4 +1,5 @@
import type { Session } from './session'
import type { TerminalSessionTeardown } from './terminal-session-teardown'
import type { TakePendingOutputResult, TerminalSnapshot } from './types'
function checkpointTerminalHostSessions(
@@ -28,17 +29,19 @@ function checkpointTerminalHostSessions(
}
}
async function disposeTerminalHostSessions(sessions: Iterable<Session>): Promise<void> {
async function disposeTerminalHostSessions(
sessions: ReadonlyMap<string, Session>,
sessionTeardown: TerminalSessionTeardown
): Promise<void> {
const results = await Promise.allSettled(
[...sessions].map(async (session) => {
[...sessions].map(async ([sessionId, session]) => {
session.detachAllClients()
// Why: live children retain native ownership until physical exit, while
// exited children must release handles without signalling a recycled pid.
if (session.isAlive) {
await session.forceKillAndDisposeSubprocess()
} else {
session.disposeSubprocess()
await sessionTeardown.killSession(sessionId, session, true)
}
session.disposeSubprocess()
})
)
const rejected = results.find(
@@ -51,6 +54,7 @@ async function disposeTerminalHostSessions(sessions: Iterable<Session>): Promise
export async function shutdownTerminalHostSessions(
sessions: Map<string, Session>,
sessionTeardown: TerminalSessionTeardown,
onFinalCheckpoint?: (
sessionId: string,
snapshot: TerminalSnapshot,
@@ -58,6 +62,6 @@ export async function shutdownTerminalHostSessions(
) => void
): Promise<void> {
checkpointTerminalHostSessions(sessions, onFinalCheckpoint)
await disposeTerminalHostSessions(sessions.values())
await disposeTerminalHostSessions(sessions, sessionTeardown)
sessions.clear()
}
+3
View File
@@ -630,6 +630,9 @@ describe('TerminalHost', () => {
await killing
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
killWithDescendantSweepMock.mockImplementation(async (_pid: number, finish: () => void) =>
finish()
)
await expect(
host.createOrAttach({
sessionId: 'agent-natural-exit',
+1 -1
View File
@@ -357,7 +357,7 @@ export class TerminalHost {
}
private async disposeSessions(): Promise<void> {
await shutdownTerminalHostSessions(this.sessions, this.onFinalCheckpoint)
await shutdownTerminalHostSessions(this.sessions, this.sessionTeardown, this.onFinalCheckpoint)
this.killedTombstones.clear()
}
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from './session'
import { TerminalSessionTeardown } from './terminal-session-teardown'
import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown'
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
const requestWindowsDescendantTreeTerminationMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
vi.mock('../windows-pty-descendant-termination', () => ({
requestWindowsDescendantTreeTermination: requestWindowsDescendantTreeTerminationMock,
WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS: 8_000
}))
type MockSession = Session & {
setAlive(value: boolean): void
terminateJobTree: ReturnType<typeof vi.fn>
forceKillAndWaitForExit: ReturnType<typeof vi.fn>
}
function createSession(opts: { agent?: boolean; nativeWindows?: boolean; hasJob?: boolean } = {}) {
let alive = true
return {
pid: 99999,
launchAgent: opts.agent ? 'claude' : null,
ownsNativeWindowsPty: opts.nativeWindows === true,
get isAlive() {
return alive
},
isTerminating: false,
beginTermination: vi.fn(() => true),
setAlive(value: boolean) {
alive = value
},
terminateJobTree: vi.fn(() => (opts.hasJob === false ? undefined : Promise.resolve(true))),
forceKillAndWaitForExit: vi.fn(async () => undefined),
kill: vi.fn(),
scheduleForceDisposeFallback: vi.fn(),
signalTerminationRoot: vi.fn()
} as unknown as MockSession
}
describe('TerminalSessionTeardown Windows descendant termination', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
let sessions: Map<string, Session>
let teardown: TerminalSessionTeardown
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
killWithDescendantSweepMock.mockReset()
requestWindowsDescendantTreeTerminationMock.mockReset()
requestWindowsDescendantTreeTerminationMock.mockResolvedValue(undefined)
sessions = new Map()
teardown = new TerminalSessionTeardown(sessions)
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('terminates an agent Job before force-killing its root', async () => {
const session = createSession({ agent: true, nativeWindows: true })
sessions.set('agent', session)
await teardown.killSession('agent', session, true)
expect(session.beginTermination).toHaveBeenCalledOnce()
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
expect(session.terminateJobTree).toHaveBeenCalledOnce()
expect(session.terminateJobTree.mock.invocationCallOrder[0]).toBeLessThan(
session.forceKillAndWaitForExit.mock.invocationCallOrder[0]!
)
expect(requestWindowsDescendantTreeTerminationMock).not.toHaveBeenCalled()
})
it('rejects fail-closed after closing the root when the agent Job does not drain', async () => {
const session = createSession({ agent: true, nativeWindows: true })
session.terminateJobTree.mockResolvedValue(false)
sessions.set('agent', session)
await expect(teardown.killSession('agent', session, true)).rejects.toThrow(
'Windows PTY Job did not drain'
)
expect(session.forceKillAndWaitForExit).toHaveBeenCalledOnce()
expect(teardown.get('agent')).toBeUndefined()
})
it('waits for root failure and retains both Windows teardown errors', async () => {
const session = createSession({ agent: true, nativeWindows: true })
const rootError = new Error('root exit timed out')
session.terminateJobTree.mockRejectedValue(new Error('Job termination failed'))
session.forceKillAndWaitForExit.mockRejectedValue(rootError)
sessions.set('agent', session)
const killing = teardown.killSession('agent', session, true)
await expect(killing).rejects.toMatchObject({
message: 'Agent descendant teardown and root exit both failed',
errors: [expect.objectContaining({ message: 'Job termination failed' }), rootError]
})
expect(session.forceKillAndWaitForExit).toHaveBeenCalledOnce()
expect(teardown.get('agent')).toBeUndefined()
})
it('uses taskkill before the agent root when Job assignment was unavailable', async () => {
const session = createSession({ agent: true, nativeWindows: true, hasJob: false })
sessions.set('agent', session)
await expect(teardown.killSession('agent', session, true)).rejects.toThrow(
'Windows PTY Job ownership unavailable'
)
expect(requestWindowsDescendantTreeTerminationMock).toHaveBeenCalledWith(99999)
expect(requestWindowsDescendantTreeTerminationMock.mock.invocationCallOrder[0]).toBeLessThan(
session.forceKillAndWaitForExit.mock.invocationCallOrder[0]!
)
})
it('preserves native Job descendants for plain terminals', async () => {
const normal = createSession({ nativeWindows: true })
sessions.set('normal', normal)
await teardown.killSession('normal', normal, true)
expect(normal.terminateJobTree).not.toHaveBeenCalled()
})
it('does not signal a root that exits while taskkill is pending', async () => {
let finishFallback!: () => void
requestWindowsDescendantTreeTerminationMock.mockReturnValue(
new Promise<void>((resolve) => {
finishFallback = resolve
})
)
const session = createSession({ agent: true, nativeWindows: true, hasJob: false })
sessions.set('agent', session)
const killing = teardown.killSession('agent', session, true)
session.setAlive(false)
sessions.delete('agent')
finishFallback()
await expect(killing).rejects.toThrow('Windows PTY Job ownership unavailable')
expect(session.forceKillAndWaitForExit).not.toHaveBeenCalled()
})
it('routes daemon-wide shutdown through coordinated session teardown', async () => {
const session = createSession({ agent: true, nativeWindows: true })
const detachAllClients = vi.fn()
const disposeSubprocess = vi.fn()
Object.assign(session, { detachAllClients, disposeSubprocess })
sessions.set('agent', session)
const killSession = vi.fn(async () => undefined)
await shutdownTerminalHostSessions(sessions, {
killSession
} as unknown as TerminalSessionTeardown)
expect(killSession).toHaveBeenCalledWith('agent', session, true)
expect(detachAllClients).toHaveBeenCalledOnce()
expect(disposeSubprocess).toHaveBeenCalledOnce()
expect(sessions.size).toBe(0)
})
})
+81 -27
View File
@@ -1,4 +1,8 @@
import { killWithDescendantSweep } from '../pty-descendant-termination'
import {
requestWindowsDescendantTreeTermination,
WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS
} from '../windows-pty-descendant-termination'
import type { Session } from './session'
type AgentTeardownOperation = {
@@ -9,8 +13,8 @@ type AgentTeardownOperation = {
session: Session
}
/** Owns agent teardown by session id until descendant capture and root
* signalling finish, even when the root exits and its Session is reaped. */
/** Owns agent teardown by session id until descendant termination and root
* signalling finish, even if the Session is reaped. */
export class TerminalSessionTeardown {
private operations = new Map<string, AgentTeardownOperation>()
@@ -48,6 +52,20 @@ export class TerminalSessionTeardown {
sessionId: string,
session: Session,
immediate: boolean
): void | Promise<void> {
return this.killCoordinatedSession(sessionId, session, immediate, (killRoot, ownsRoot) => {
if (process.platform === 'win32' && session.ownsNativeWindowsPty) {
return this.terminateWindowsTree(session, killRoot, ownsRoot)
}
return killWithDescendantSweep(session.pid, killRoot, { ownsRoot })
})
}
private killCoordinatedSession(
sessionId: string,
session: Session,
immediate: boolean,
prepareRootKill: (killRoot: () => void, ownsRoot: () => boolean) => Promise<void>
): void | Promise<void> {
const pending = this.operations.get(sessionId)
if (pending) {
@@ -76,32 +94,39 @@ export class TerminalSessionTeardown {
rootCompletion: Promise.resolve(),
session
}
const sweep = Promise.resolve(
killWithDescendantSweep(
session.pid,
() => {
// Why: natural exit reaps the PID while ps is running. Never signal that
// stale numeric PID after the Session no longer represents a live root.
if (!session.isAlive) {
return
}
entry.rootSignalled = true
if (entry.immediate) {
entry.rootCompletion = session.forceKillAndWaitForExit()
} else {
session.signalTerminationRoot()
}
},
{
// Why: the descendant rows are only authoritative while this exact
// Session still owns the root PID captured by ps.
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive
}
)
)
const ownsRoot = (): boolean => this.sessions.get(sessionId) === session && session.isAlive
const killRoot = (): void => {
// Why: natural exit reaps the PID while preparation is running. Never
// signal that stale numeric PID after this Session loses the live root.
if (!ownsRoot()) {
return
}
entry.rootSignalled = true
if (entry.immediate) {
entry.rootCompletion = session.forceKillAndWaitForExit()
} else {
session.signalTerminationRoot()
}
}
const sweep = Promise.resolve(prepareRootKill(killRoot, ownsRoot))
// Why: descendant capture completion only proves signals were requested;
// destructive callers must retain the native owner until OS-confirmed exit.
const operation = sweep.then(() => entry.rootCompletion)
// callers must retain the native owner until OS-confirmed exit.
const operation = sweep.then(
() => entry.rootCompletion,
async (preparationError: unknown) => {
try {
// Why: Windows failure paths still signal the root. Observe that
// completion before rejecting so its timeout cannot escape unhandled.
await entry.rootCompletion
} catch (rootError) {
throw new AggregateError(
[preparationError, rootError],
'Agent descendant teardown and root exit both failed'
)
}
throw preparationError
}
)
entry.promise = operation
this.operations.set(sessionId, entry)
const clearOperation = (): void => {
@@ -112,4 +137,33 @@ export class TerminalSessionTeardown {
void operation.then(clearOperation, clearOperation)
return operation
}
private async terminateWindowsTree(
session: Session,
killRoot: () => void,
ownsRoot: () => boolean
): Promise<void> {
const nativeCompletion = session.terminateJobTree(WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS)
if (nativeCompletion) {
killRoot()
if (!(await nativeCompletion)) {
throw new Error(`Windows PTY Job did not drain for process ${session.pid}`)
}
return
}
if (!ownsRoot()) {
throw new Error(`Windows PTY root exited without Job ownership for process ${session.pid}`)
}
try {
await requestWindowsDescendantTreeTermination(session.pid)
// taskkill cannot identify a descendant after its launcher exits, so a
// successful request is cleanup only and never authorizes deletion.
throw new Error(`Windows PTY Job ownership unavailable for process ${session.pid}`)
} finally {
// Why: fallback failure must not leave the agent root running, while the
// rejected operation still keeps destructive callers fail-closed.
killRoot()
}
}
}
+111 -2
View File
@@ -14,7 +14,8 @@ const {
resolveAgentForegroundProcessMock,
readWindowsConptyProcessIdsMock,
captureDescendantSnapshotMock,
terminateDescendantSnapshotMock
terminateDescendantSnapshotMock,
requestWindowsDescendantTreeTerminationMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
@@ -26,7 +27,8 @@ const {
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsConptyProcessIdsMock: vi.fn(),
captureDescendantSnapshotMock: vi.fn(),
terminateDescendantSnapshotMock: vi.fn()
terminateDescendantSnapshotMock: vi.fn(),
requestWindowsDescendantTreeTerminationMock: vi.fn()
}))
vi.mock('fs', () => ({
@@ -59,6 +61,11 @@ vi.mock('../pty-descendant-termination', () => ({
terminateDescendantSnapshot: terminateDescendantSnapshotMock
}))
vi.mock('../windows-pty-descendant-termination', () => ({
requestWindowsDescendantTreeTermination: requestWindowsDescendantTreeTerminationMock,
WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS: 8_000
}))
// Resolve PowerShell family names to deterministic absolute paths (the fs mock
// above otherwise makes every probe miss). The real resolver — which skips the
// Store App Execution Alias stub — is covered in
@@ -125,6 +132,7 @@ describe('LocalPtyProvider', () => {
pause: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
terminateJobTree: ReturnType<typeof vi.fn>
process: string
pid: number
}
@@ -155,6 +163,8 @@ describe('LocalPtyProvider', () => {
terminateDescendantSnapshotMock.mockReset()
prepareMacosTccLoginShellMock.mockReset()
prepareMacosTccLoginShellMock.mockResolvedValue(undefined)
requestWindowsDescendantTreeTerminationMock.mockReset()
requestWindowsDescendantTreeTerminationMock.mockResolvedValue(undefined)
resolveAgentForegroundProcessMock.mockReset()
resolveAgentForegroundProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => ({
@@ -185,6 +195,7 @@ describe('LocalPtyProvider', () => {
kill: vi.fn(() => {
exitCb?.({ exitCode: -1 })
}),
terminateJobTree: vi.fn(async () => true),
process: 'zsh',
pid: 12345
}
@@ -1145,6 +1156,104 @@ describe('LocalPtyProvider', () => {
expect(destroySpy).not.toHaveBeenCalled()
})
it('terminates an agent Job before closing its Windows ConPTY', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const order: string[] = []
mockProc.terminateJobTree.mockImplementation(async () => {
order.push('job')
return true
})
mockProc.kill.mockImplementation(() => {
order.push('conpty')
exitCb?.({ exitCode: 1 })
})
const { id } = await provider.spawn({ cols: 80, rows: 24, launchAgent: 'claude' })
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ windowsAgentJob: true })
)
await provider.shutdown(id, { immediate: true })
expect(order).toEqual(['job', 'conpty'])
expect(requestWindowsDescendantTreeTerminationMock).not.toHaveBeenCalled()
})
it('rejects fail-closed after closing ConPTY when the agent Job does not drain', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.terminateJobTree.mockResolvedValue(false)
const { id } = await provider.spawn({ cols: 80, rows: 24, launchAgent: 'claude' })
await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow(
'Windows PTY Job did not drain'
)
expect(mockProc.kill).toHaveBeenCalledOnce()
expect(provider.hasPty(id)).toBe(false)
})
it('closes ConPTY and rejects when native agent Job termination throws', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.terminateJobTree.mockRejectedValue(new Error('native Job failure'))
const { id } = await provider.spawn({ cols: 80, rows: 24, launchAgent: 'claude' })
await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow('native Job failure')
expect(mockProc.kill).toHaveBeenCalledOnce()
expect(provider.hasPty(id)).toBe(false)
})
it('preserves plain detached-child semantics for normal Windows tab close', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
expect(mockProc.terminateJobTree).not.toHaveBeenCalled()
expect(requestWindowsDescendantTreeTerminationMock).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.not.objectContaining({ windowsAgentJob: true })
)
})
it('falls back to taskkill when the Windows PTY has no Job Object', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.terminateJobTree.mockReturnValue(undefined)
const { id } = await provider.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow(
'Windows PTY Job ownership unavailable'
)
expect(requestWindowsDescendantTreeTerminationMock).toHaveBeenCalledWith(mockProc.pid)
expect(requestWindowsDescendantTreeTerminationMock.mock.invocationCallOrder[0]).toBeLessThan(
mockProc.kill.mock.invocationCallOrder[0]!
)
})
it('never runs native Windows tree termination for a WSL terminal', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
launchAgent: 'claude'
})
await provider.shutdown(id, { immediate: true })
expect(mockProc.terminateJobTree).not.toHaveBeenCalled()
expect(requestWindowsDescendantTreeTerminationMock).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.not.objectContaining({ windowsAgentJob: true })
)
})
it('keeps shutdown and ownership pending until node-pty reports physical exit', async () => {
const killSpy = vi.fn()
mockProc.kill = killSpy
+61 -7
View File
@@ -60,6 +60,10 @@ import {
captureDescendantSnapshot,
terminateDescendantSnapshot
} from '../pty-descendant-termination'
import {
requestWindowsDescendantTreeTermination,
WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS
} from '../windows-pty-descendant-termination'
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { canConfirmAgentFromConsolePresence } from './windows-console-foreground'
import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups'
@@ -282,6 +286,35 @@ function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void {
forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL'))
}
type WindowsPtyJobControl = pty.IPty & {
terminateJobTree?(timeoutMs: number): Promise<boolean> | undefined
}
async function terminateWindowsPtyTree(proc: pty.IPty, closeRoot: () => void): Promise<void> {
const nativeCompletion = (proc as WindowsPtyJobControl).terminateJobTree?.call(
proc,
WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS
)
if (nativeCompletion) {
closeRoot()
if (!(await nativeCompletion)) {
throw new Error(`Windows PTY Job did not drain for process ${proc.pid}`)
}
return
}
try {
await requestWindowsDescendantTreeTermination(proc.pid)
// taskkill cannot identify a descendant after its launcher exits, so a
// successful request is cleanup only and never authorizes deletion.
throw new Error(`Windows PTY Job ownership unavailable for process ${proc.pid}`)
} finally {
// Why: fallback failure must not leave the agent root alive, but callers
// still receive the failure instead of treating worktree handles as free.
closeRoot()
}
}
function armLocalPtyForceKill(
id: string,
proc: pty.IPty,
@@ -878,7 +911,13 @@ export class LocalPtyProvider implements IPtyProvider {
onBeforeFallbackSpawn: historyResult?.histFile
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
: undefined,
windowsFallbackAttempts
windowsFallbackAttempts,
// Why: only recognized native agents need descendant ownership. WSL
// belongs to a separate execution host and plain terminals stay unchanged.
windowsAgentJob:
process.platform === 'win32' &&
!isWslTerminal &&
Boolean(args.launchAgent || startupAgentRecognition)
})
shellPath = spawnResult.shellPath
// Why: a Windows fallback (e.g. cmd.exe) embeds its own startup command in
@@ -1176,12 +1215,16 @@ export class LocalPtyProvider implements IPtyProvider {
const physicalExit = ptyPhysicalExits.get(id)
// Why: the snapshot must precede any signal — once the shell dies,
// surviving descendants reparent to pid 1 and a ppid walk can't find them.
const descendants = ptyAgentSessionIds.has(id)
? await captureDescendantSnapshot(proc.pid)
: null
// Why: a natural exit can race the snapshot. Never signal descendants or
// a root PID after this exact PTY has lost ownership.
if (ptyProcesses.get(id) === proc) {
const descendants =
process.platform !== 'win32' && ptyAgentSessionIds.has(id)
? await captureDescendantSnapshot(proc.pid)
: null
const closeRoot = (): void => {
// Why: a natural exit can race teardown preparation. Never signal a
// numeric PID after this exact PTY loses ownership.
if (ptyProcesses.get(id) !== proc) {
return
}
if (descendants) {
terminateDescendantSnapshot(descendants)
}
@@ -1191,6 +1234,17 @@ export class LocalPtyProvider implements IPtyProvider {
operation.rootSignalled = true
this.requestTrackedPtyShutdown(id, proc, operation.immediate)
}
if (
process.platform === 'win32' &&
// Why: wsl.exe only launches a Linux tree; Windows Job/taskkill cannot
// prove teardown of descendants owned by that separate execution host.
ptyWslDistroById.get(id) === null &&
ptyAgentSessionIds.has(id)
) {
await terminateWindowsPtyTree(proc, closeRoot)
} else {
closeRoot()
}
await waitForPtyPhysicalExit(id, physicalExit)
}
+10 -4
View File
@@ -163,6 +163,8 @@ export type ShellSpawnParams = {
* fails to spawn, the next real absolute executable is tried with its own
* recomputed args/cwd. */
windowsFallbackAttempts?: WindowsShellSpawnAttempt[]
/** Assign native Windows agent shells to Orca's descendant-owning Job. */
windowsAgentJob?: boolean
}
export type ShellSpawnResult = {
@@ -186,8 +188,12 @@ export type ShellSpawnResult = {
// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can
// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and
// fresh-local spawns silently behave differently from daemon terminals.
function windowsConptyDllOptions(): { useConptyDll: true } | Record<string, never> {
return process.platform === 'win32' ? { useConptyDll: true } : {}
function windowsConptyDllOptions(
windowsAgentJob?: boolean
): { useConptyDll: true; windowsAgentJob?: true } | Record<string, never> {
return process.platform === 'win32'
? { useConptyDll: true, ...(windowsAgentJob ? { windowsAgentJob: true } : {}) }
: {}
}
function spawnWindowsFallbackChain(
@@ -205,7 +211,7 @@ function spawnWindowsFallbackChain(
rows,
cwd: attempt.effectiveCwd,
env,
...windowsConptyDllOptions()
...windowsConptyDllOptions(params.windowsAgentJob)
})
console.warn(
`[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"`
@@ -255,7 +261,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
rows,
cwd,
env,
...windowsConptyDllOptions()
...windowsConptyDllOptions(params.windowsAgentJob)
}),
shellPath
}
@@ -0,0 +1,92 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
requestWindowsDescendantTreeTermination,
WINDOWS_DESCENDANT_KILL_TIMEOUT_MS,
type WindowsTreeKillSpawner
} from './windows-pty-descendant-termination'
function createChild(): Pick<ChildProcess, 'kill' | 'once' | 'unref'> & EventEmitter {
const child = new EventEmitter() as Pick<ChildProcess, 'kill' | 'once' | 'unref'> & EventEmitter
child.kill = vi.fn(() => true)
child.unref = vi.fn()
return child
}
describe('requestWindowsDescendantTreeTermination', () => {
afterEach(() => {
vi.useRealTimers()
})
it('runs the System32 taskkill binary with a forceful tree argv', async () => {
const child = createChild()
const spawnProcess = vi.fn(() => child) as unknown as WindowsTreeKillSpawner
const killing = requestWindowsDescendantTreeTermination(4321, {
spawn: spawnProcess,
env: { SystemRoot: 'C:\\Windows' }
})
expect(spawnProcess).toHaveBeenCalledWith(
'C:\\Windows\\System32\\taskkill.exe',
['/PID', '4321', '/T', '/F'],
{ shell: false, stdio: 'ignore', windowsHide: true }
)
expect(child.unref).toHaveBeenCalledOnce()
child.emit('exit', 0, null)
await expect(killing).resolves.toBeUndefined()
})
it('falls back to the bare binary and reports a non-zero exit', async () => {
const child = createChild()
const spawnProcess = vi.fn(() => child) as unknown as WindowsTreeKillSpawner
const killing = requestWindowsDescendantTreeTermination(77, {
spawn: spawnProcess,
env: {}
})
expect(spawnProcess).toHaveBeenCalledWith(
'taskkill',
['/PID', '77', '/T', '/F'],
expect.any(Object)
)
child.emit('exit', 128, null)
await expect(killing).rejects.toThrow('taskkill exited 128')
})
it.each([0, -1, 1.5, Number.NaN, undefined])(
'does not spawn for invalid pid %s',
async (rootPid) => {
const spawnProcess = vi.fn() as unknown as WindowsTreeKillSpawner
await expect(
requestWindowsDescendantTreeTermination(rootPid as number, { spawn: spawnProcess })
).rejects.toThrow('Invalid Windows PTY root PID')
expect(spawnProcess).not.toHaveBeenCalled()
}
)
it('bounds a hung taskkill process and cancels it', async () => {
vi.useFakeTimers()
const child = createChild()
const spawnProcess = vi.fn(() => child) as unknown as WindowsTreeKillSpawner
const killing = requestWindowsDescendantTreeTermination(99, { spawn: spawnProcess })
const rejection = expect(killing).rejects.toThrow('taskkill timed out')
await vi.advanceTimersByTimeAsync(WINDOWS_DESCENDANT_KILL_TIMEOUT_MS)
expect(child.kill).toHaveBeenCalledOnce()
await rejection
})
it('reports spawn errors instead of treating them as proof', async () => {
const child = createChild()
const spawnProcess = vi.fn(() => child) as unknown as WindowsTreeKillSpawner
const killing = requestWindowsDescendantTreeTermination(123, { spawn: spawnProcess })
child.emit('error', new Error('blocked'))
await expect(killing).rejects.toThrow('taskkill failed')
})
})
@@ -0,0 +1,89 @@
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import { win32 as pathWin32 } from 'node:path'
export const WINDOWS_DESCENDANT_KILL_TIMEOUT_MS = 2_000
export const WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS = 8_000
type WindowsTreeKillProcess = Pick<ChildProcess, 'kill' | 'once' | 'unref'>
export type WindowsTreeKillSpawner = (
file: string,
args: string[],
options: SpawnOptions
) => WindowsTreeKillProcess
type WindowsTreeKillDeps = {
spawn?: WindowsTreeKillSpawner
env?: NodeJS.ProcessEnv
timeoutMs?: number
}
/**
* Best-effort fallback for Windows hosts where node-pty could not assign the
* shell to a Job Object. taskkill follows only live PPID links, so it cannot
* reach a detached child whose launcher already exited. It also accepts only
* a PID, leaving a small reuse window after node-pty closes the shell handle.
*/
export function requestWindowsDescendantTreeTermination(
rootPid: number,
deps: WindowsTreeKillDeps = {}
): Promise<void> {
if (!Number.isInteger(rootPid) || rootPid <= 0) {
return Promise.reject(new Error(`Invalid Windows PTY root PID: ${rootPid}`))
}
const systemRoot = (deps.env ?? process.env).SystemRoot
const taskkillPath = systemRoot
? pathWin32.join(systemRoot, 'System32', 'taskkill.exe')
: 'taskkill'
const spawnProcess: WindowsTreeKillSpawner = deps.spawn ?? spawn
return new Promise((resolve, reject) => {
let settled = false
let child: WindowsTreeKillProcess | undefined
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
if (error) {
reject(error)
} else {
resolve()
}
}
const timer = setTimeout(() => {
try {
child?.kill()
} catch {
/* taskkill may have exited between the deadline and cancellation */
}
finish(new Error(`taskkill timed out for Windows PTY root ${rootPid}`))
}, deps.timeoutMs ?? WINDOWS_DESCENDANT_KILL_TIMEOUT_MS)
timer.unref?.()
try {
child = spawnProcess(taskkillPath, ['/PID', String(rootPid), '/T', '/F'], {
shell: false,
stdio: 'ignore',
windowsHide: true
})
child.once('error', (error) =>
finish(new Error(`taskkill failed for Windows PTY root ${rootPid}: ${error.message}`))
)
child.once('exit', (code) =>
finish(
code === 0
? undefined
: new Error(
`taskkill exited ${code ?? 'without a code'} for Windows PTY root ${rootPid}`
)
)
)
child.unref()
} catch {
finish(new Error(`Could not start taskkill for Windows PTY root ${rootPid}`))
}
})
}