fix(windows): close teardown review gaps

This commit is contained in:
OrcaWin
2026-07-20 12:41:33 -07:00
parent 35ed9439c3
commit f16ce1223c
15 changed files with 746 additions and 454 deletions
@@ -32,7 +32,6 @@ on:
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/preload/**'
- 'src/relay/**'
- 'src/renderer/src/components/terminal/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
@@ -60,8 +59,8 @@ jobs:
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: failed native connect cleanup is testable without shipping fault
# injection in production node-pty builds.
# Why: native connect/Job allocation cleanup is testable without shipping
# fault injection in production node-pty builds.
GYP_DEFINES: node_pty_test_faults=1
steps:
@@ -86,14 +85,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run real Windows SSH relay Job integration
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=node
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec vitest run src/relay/windows-agent-job.integration.test.ts --config config/vitest.config.ts
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify generated node-pty projects retain Spectre mitigation
shell: pwsh
run: |
@@ -116,13 +107,16 @@ jobs:
pnpm exec electron-vite build --mode e2e
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Run patched node-pty failed-connect cleanup regressions
- name: Run patched node-pty capability and cleanup regressions
env:
ELECTRON_RUN_AS_NODE: '1'
NODE_PTY_REQUIRE_TEST_FAULTS: '1'
run: >-
pnpm exec electron
node_modules/node-pty/lib/conptyConnectFailure.test.js
shell: pwsh
run: |
pnpm exec electron node_modules/node-pty/lib/conptyConnectFailure.test.js
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron node_modules/node-pty/lib/conptyJobTerminationFailure.test.js
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Run Windows terminal restart regressions
env:
+375 -116
View File
@@ -120,8 +120,62 @@ index 0000000000000000000000000000000000000000..b149dd006735ddf64ea3f2b167c0271f
+ console.log('ConPTY failed-connect cleanup: 2 passed');
+ }
+}
diff --git a/lib/conptyJobTerminationFailure.test.js b/lib/conptyJobTerminationFailure.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..70f0d5757db2aafe117adf3f5ca053031f56020e
--- /dev/null
+++ b/lib/conptyJobTerminationFailure.test.js
@@ -0,0 +1,48 @@
+"use strict";
+/**
+ * Copyright (c) 2026, Stably AI (MIT License).
+ */
+Object.defineProperty(exports, "__esModule", { value: true });
+var assert = require("assert");
+var utils_1 = require("./utils");
+var windowsPtyAgent_1 = require("./windowsPtyAgent");
+if (process.platform === 'win32') {
+ var native_1 = utils_1.loadNativeModule('conpty').module;
+ var environment_1 = Object.keys(process.env)
+ .filter(function (key) { return typeof process.env[key] === 'string'; })
+ .map(function (key) { return key + "=" + process.env[key]; });
+ var createAgent_1 = function () { return new windowsPtyAgent_1.WindowsPtyAgent(process.env.ComSpec || 'cmd.exe', '/D /S /C "ping -n 30 127.0.0.1 > nul"', environment_1, process.cwd(), 80, 30, false, true, true, false, true); };
+ var runFailureCase_1 = function (failure, expectedError) {
+ assert.ok(native_1.setTestTerminateJobFailure && native_1.getTestResourceState);
+ var agent = createAgent_1();
+ var before = native_1.getTestResourceState();
+ native_1.setTestTerminateJobFailure(failure);
+ try {
+ assert.throws(function () { return agent.terminateJobTree(1000); }, expectedError);
+ var after = native_1.getTestResourceState();
+ assert.strictEqual(after.closedTerminationJobHandleCount, before.closedTerminationJobHandleCount + 1);
+ assert.strictEqual(after.liveTerminationThreadCount, before.liveTerminationThreadCount);
+ }
+ finally {
+ native_1.setTestTerminateJobFailure('none');
+ agent.kill();
+ }
+ };
+ var runAssertions_1 = function () {
+ assert.ok(native_1.setTestTerminateJobFailure && native_1.getTestResourceState, 'ConPTY termination test fault injection was required but is unavailable');
+ assert.throws(function () { return new windowsPtyAgent_1.WindowsPtyAgent(process.env.ComSpec || 'cmd.exe', '/C exit', environment_1, process.cwd(), 80, 30, false, false, false, false, true); }, /Windows agent Job ownership requires ConPTY/);
+ runFailureCase_1('thread-allocation', /Injected termination thread allocation failure/);
+ runFailureCase_1('threadsafe-function', /Injected termination ThreadSafeFunction failure/);
+ };
+ if (typeof describe === 'function') {
+ describe('ConPTY Job capability and termination cleanup', function () {
+ it('fails closed without ConPTY and releases allocation-failure resources', function () {
+ runAssertions_1();
+ });
+ });
+ }
+ else {
+ runAssertions_1();
+ console.log('ConPTY Job capability and termination cleanup: 3 passed');
+ }
+}
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0b71eba1d0fdb312bd46e5f47c218bd5330f023a 100644
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 });
@@ -142,7 +196,7 @@ index 8c4fca9022a6d6f015bca87f61625cde2278f428..0b71eba1d0fdb312bd46e5f47c218bd5
//# 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..24cbb71bce12f8e6ec617918df1e16bafd6adff1 100644
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -161,10 +215,10 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..24cbb71bce12f8e6ec617918df1e16ba
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fce65cbb2c 100644
index a358ffb177357e177661033c1b092f9c9d0e5f5a..6d5a00662763c377371603fbb8e5119a097cea3f 100644
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -26,17 +26,23 @@ var FLUSH_DATA_INTERVAL = 1000;
@@ -26,17 +26,26 @@ var FLUSH_DATA_INTERVAL = 1000;
* and winpty.
*/
var WindowsPtyAgent = /** @class */ (function () {
@@ -184,12 +238,15 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
if (this._useConpty === undefined || this._useConpty === true) {
this._useConpty = this._getWindowsBuildNumber() >= 18309;
}
+ // Why: a requested Job is effective only when runtime detection actually selects ConPTY.
+ this._windowsAgentJob = Boolean(this._useConpty && windowsAgentJob);
+ // Why: winpty cannot provide spawn-time Job ownership; launching anyway can orphan agents.
+ if (windowsAgentJob && !this._useConpty) {
+ throw new Error('Windows agent Job ownership requires ConPTY');
+ }
+ this._windowsAgentJob = windowsAgentJob;
if (this._useConpty) {
if (!conptyNative) {
conptyNative = utils_1.loadNativeModule('conpty').module;
@@ -72,7 +78,7 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -72,7 +81,7 @@ var WindowsPtyAgent = /** @class */ (function () {
this._outSocket.setEncoding('utf8');
// The conout socket must be ready out on another thread to avoid deadlocks
this._conoutSocketWorker = new windowsConoutConnection_1.ConoutConnection(term.conout, this._useConptyDll);
@@ -198,7 +255,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
_this._conoutSocketWorker.connectSocket(_this._outSocket);
});
this._outSocket.on('connect', function () {
@@ -86,8 +92,19 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -86,8 +95,19 @@ var WindowsPtyAgent = /** @class */ (function () {
});
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
@@ -220,7 +277,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
}
}
Object.defineProperty(WindowsPtyAgent.prototype, "inSocket", {
@@ -115,6 +132,11 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -115,6 +135,11 @@ var WindowsPtyAgent = /** @class */ (function () {
enumerable: false,
configurable: true
});
@@ -232,7 +289,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
WindowsPtyAgent.prototype.resize = function (cols, rows) {
if (this._useConpty) {
if (this._exitCode !== undefined) {
@@ -130,6 +152,23 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -130,6 +155,23 @@ var WindowsPtyAgent = /** @class */ (function () {
this._ptyNative.clear(this._pty, this._useConptyDll);
}
};
@@ -256,7 +313,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
WindowsPtyAgent.prototype.kill = function () {
var _this = this;
// Tell the agent to kill the pty, this releases handles to the process
@@ -221,6 +260,10 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -221,6 +263,10 @@ var WindowsPtyAgent = /** @class */ (function () {
WindowsPtyAgent.prototype._$onProcessExit = function (exitCode) {
var _this = this;
this._exitCode = exitCode;
@@ -268,7 +325,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..530171dc8000f3acdd6d5a82fcdc00fc
this._flushDataAndCleanUp();
this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });
diff --git a/lib/windowsPtyAgent.test.js b/lib/windowsPtyAgent.test.js
index 15bbf5ba397ab02a5ec6a11b1c0c6f6ffd137be0..d9a0cf1af2da1cdd56ff6edae0bfa41580b51c60 100644
index 15bbf5ba397ab02a5ec6a11b1c0c6f6ffd137be0..b8c2a60ca5e021e3fe2a73762f0000da35a7fc1d 100644
--- a/lib/windowsPtyAgent.test.js
+++ b/lib/windowsPtyAgent.test.js
@@ -10,6 +10,26 @@ function check(file, args, expected) {
@@ -299,7 +356,7 @@ index 15bbf5ba397ab02a5ec6a11b1c0c6f6ffd137be0..d9a0cf1af2da1cdd56ff6edae0bfa415
describe('Plain strings', function () {
it('doesn\'t quote plain string', function () {
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
index 3c38f89d14cece8081782a9600ebc582b7c2a62a..52e47c98194daf6f64b22fcaa647077acf989371 100644
index 3c38f89d14cece8081782a9600ebc582b7c2a62a..1cb42dc5d6a78e502673ff84982e8904555c1486 100644
--- a/lib/windowsTerminal.js
+++ b/lib/windowsTerminal.js
@@ -48,8 +48,23 @@ var WindowsTerminal = /** @class */ (function (_super) {
@@ -351,24 +408,19 @@ index 3c38f89d14cece8081782a9600ebc582b7c2a62a..52e47c98194daf6f64b22fcaa647077a
var _this = this;
this._deferNoArgs(function () {
diff --git a/lib/windowsTerminal.test.js b/lib/windowsTerminal.test.js
index af5f343d17f012f330fe2292083f297768ff3b5e..6b558c789fc77b58c20cd9564d03e93e54164f54 100644
index af5f343d17f012f330fe2292083f297768ff3b5e..a52859c445de83e033e69d880115051f010ff097 100644
--- a/lib/windowsTerminal.test.js
+++ b/lib/windowsTerminal.test.js
@@ -88,6 +88,19 @@ function pollForProcessTreeSize(pid, size, intervalMs, timeoutMs) {
@@ -88,6 +88,14 @@ function pollForProcessTreeSize(pid, size, intervalMs, timeoutMs) {
});
}
if (process.platform === 'win32') {
+ describe('WindowsTerminal Job capability fallback', function () {
+ it('does not wait for a Job callback when a requested Job falls back to winpty', function (done) {
+ this.timeout(10000);
+ var term = new windowsTerminal_1.WindowsTerminal('cmd.exe', '/C exit', {
+ it('fails closed before a requested Job can fall back to winpty', function () {
+ assert.throws(function () { return new windowsTerminal_1.WindowsTerminal('cmd.exe', '/C exit', {
+ useConpty: false,
+ windowsAgentJob: true
+ });
+ term.on('exit', function (code) {
+ assert.strictEqual(code, 0);
+ done();
+ });
+ }); }, /Windows agent Job ownership requires ConPTY/);
+ });
+ });
[[false, false], [true, false], [true, true]].forEach(function (_a) {
@@ -493,6 +545,105 @@ index 0000000000000000000000000000000000000000..c9a657fbcae63e66b5930b7b52aaa98e
+ console.log('ConPTY failed-connect cleanup: 2 passed');
+ }
+}
diff --git a/src/conptyJobTerminationFailure.test.ts b/src/conptyJobTerminationFailure.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d8af2df3cfa9d6d51ff35b0d9f67c40c15d26a55
--- /dev/null
+++ b/src/conptyJobTerminationFailure.test.ts
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2026, Stably AI (MIT License).
+ */
+
+import assert = require('assert');
+import { loadNativeModule } from './utils';
+import { WindowsPtyAgent } from './windowsPtyAgent';
+
+interface ITestConptyNative {
+ setTestTerminateJobFailure?(failure: string): void;
+ getTestResourceState?(): ITestResourceState;
+}
+
+interface ITestResourceState {
+ closedTerminationJobHandleCount: number;
+ liveTerminationThreadCount: number;
+}
+
+if (process.platform === 'win32') {
+ const native = loadNativeModule('conpty').module as ITestConptyNative;
+ const environment = Object.keys(process.env)
+ .filter(key => typeof process.env[key] === 'string')
+ .map(key => `${key}=${process.env[key]}`);
+
+ const createAgent = (): WindowsPtyAgent => new WindowsPtyAgent(
+ process.env.ComSpec || 'cmd.exe',
+ '/D /S /C "ping -n 30 127.0.0.1 > nul"',
+ environment,
+ process.cwd(),
+ 80,
+ 30,
+ false,
+ true,
+ true,
+ false,
+ true
+ );
+
+ const runFailureCase = (failure: string, expectedError: RegExp): void => {
+ assert.ok(native.setTestTerminateJobFailure && native.getTestResourceState);
+ const agent = createAgent();
+ const before = native.getTestResourceState();
+ native.setTestTerminateJobFailure(failure);
+ try {
+ assert.throws(() => agent.terminateJobTree(1000), expectedError);
+ const after = native.getTestResourceState();
+ assert.strictEqual(
+ after.closedTerminationJobHandleCount,
+ before.closedTerminationJobHandleCount + 1
+ );
+ assert.strictEqual(after.liveTerminationThreadCount, before.liveTerminationThreadCount);
+ } finally {
+ native.setTestTerminateJobFailure('none');
+ agent.kill();
+ }
+ };
+
+ const runAssertions = (): void => {
+ assert.ok(
+ native.setTestTerminateJobFailure && native.getTestResourceState,
+ 'ConPTY termination test fault injection was required but is unavailable'
+ );
+ assert.throws(
+ () => new WindowsPtyAgent(
+ process.env.ComSpec || 'cmd.exe',
+ '/C exit',
+ environment,
+ process.cwd(),
+ 80,
+ 30,
+ false,
+ false,
+ false,
+ false,
+ true
+ ),
+ /Windows agent Job ownership requires ConPTY/
+ );
+ runFailureCase('thread-allocation', /Injected termination thread allocation failure/);
+ runFailureCase('threadsafe-function', /Injected termination ThreadSafeFunction failure/);
+ };
+
+ if (typeof describe === 'function') {
+ describe('ConPTY Job capability and termination cleanup', () => {
+ it('fails closed without ConPTY and releases allocation-failure resources', () => {
+ runAssertions();
+ });
+ });
+ } else {
+ runAssertions();
+ console.log('ConPTY Job capability and termination cleanup: 3 passed');
+ }
+}
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
@@ -816,10 +967,10 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f231875265 100644
index 7b286d3d644c26141df516929703aa6e129df4b2..82571e671b9605c84e69d106acf3d4486e92ff89 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -45,12 +45,37 @@ struct pty_baton {
@@ -45,12 +45,40 @@ struct pty_baton {
HPCON hpc;
HANDLE hShell;
@@ -855,11 +1006,14 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+static LONG closedThreadHandleCount = 0;
+static LONG closedJobHandleCount = 0;
+static LONG drainedFailedProcessCount = 0;
+static LONG closedTerminationJobHandleCount = 0;
+static LONG allocatedTerminationThreadCount = 0;
+static LONG releasedTerminationThreadCount = 0;
+#endif
static pty_baton* get_pty_baton(int id) {
auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
@@ -73,6 +98,117 @@ static bool remove_pty_baton(int id) {
@@ -73,6 +101,147 @@ static bool remove_pty_baton(int id) {
return false;
}
@@ -936,7 +1090,14 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ ResumeThread
+};
+
+enum class TerminateJobFailurePoint {
+ None,
+ ThreadAllocation,
+ ThreadSafeFunctionCreation
+};
+
+static ConnectFailurePoint connectFailurePoint = ConnectFailurePoint::None;
+static TerminateJobFailurePoint terminateJobFailurePoint = TerminateJobFailurePoint::None;
+
+static Napi::Value PtySetTestConnectFailure(const Napi::CallbackInfo& info) {
+ if (info.Length() != 1 || !info[0].IsString()) {
@@ -953,6 +1114,21 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ return info.Env().Undefined();
+}
+
+static Napi::Value PtySetTestTerminateJobFailure(const Napi::CallbackInfo& info) {
+ if (info.Length() != 1 || !info[0].IsString()) {
+ throw Napi::Error::New(info.Env(), "Usage: pty.setTestTerminateJobFailure(failure)");
+ }
+ const std::string failure = info[0].As<Napi::String>().Utf8Value();
+ if (failure == "thread-allocation") {
+ terminateJobFailurePoint = TerminateJobFailurePoint::ThreadAllocation;
+ } else if (failure == "threadsafe-function") {
+ terminateJobFailurePoint = TerminateJobFailurePoint::ThreadSafeFunctionCreation;
+ } else {
+ terminateJobFailurePoint = TerminateJobFailurePoint::None;
+ }
+ return info.Env().Undefined();
+}
+
+static Napi::Value PtyGetTestResourceState(const Napi::CallbackInfo& info) {
+ auto result = Napi::Object::New(info.Env());
+ result.Set("ptyCount", Napi::Number::New(info.Env(), ptyHandles.size()));
@@ -970,6 +1146,14 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ result.Set(
+ "drainedFailedProcessCount",
+ Napi::Number::New(info.Env(), drainedFailedProcessCount));
+ result.Set(
+ "closedTerminationJobHandleCount",
+ Napi::Number::New(info.Env(), closedTerminationJobHandleCount));
+ result.Set(
+ "liveTerminationThreadCount",
+ Napi::Number::New(
+ info.Env(),
+ allocatedTerminationThreadCount - releasedTerminationThreadCount));
+ return result;
+}
+#endif
@@ -977,7 +1161,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
struct ExitEvent {
int exit_code = 0;
};
@@ -91,7 +227,18 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
@@ -91,7 +260,18 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
delete th;
});
*th = std::thread([tsfn = std::move(tsfn), baton] {
@@ -997,7 +1181,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
cb.Call({Napi::Number::New(env, exit_event->exit_code)});
delete exit_event;
};
@@ -99,15 +246,41 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
@@ -99,15 +279,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);
@@ -1044,7 +1228,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
break;
case napi_queue_full:
@@ -194,6 +367,8 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
@@ -194,6 +400,8 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
HANDLE *phInput,
HANDLE *phOutput,
HPCON* phPC,
@@ -1053,7 +1237,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
std::wstring& inName,
std::wstring& outName,
const std::wstring& pipeName,
@@ -214,6 +389,18 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
@@ -214,6 +422,18 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
return E_INVALIDARG;
}
@@ -1072,7 +1256,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
bool success = createDataServerPipe(true, L"in", phInput, inName, pipeName);
if (!success)
{
@@ -289,7 +476,21 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
@@ -289,7 +509,21 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
HANDLE hIn, hOut;
HPCON hpc;
@@ -1095,7 +1279,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
// Restore default handling of ctrl+c
SetConsoleCtrlHandler(NULL, FALSE);
@@ -302,7 +503,13 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
@@ -302,7 +536,13 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
const int ptyId = InterlockedIncrement(&ptyCounter);
marshal.Set("pty", Napi::Number::New(env, ptyId));
ptyHandles.emplace_back(
@@ -1110,7 +1294,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
} else {
throw Napi::Error::New(env, "Cannot launch conpty");
}
@@ -333,14 +540,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -333,14 +573,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
std::stringstream errorText;
BOOL fSuccess = FALSE;
@@ -1129,7 +1313,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
}
const int id = info[0].As<Napi::Number>().Int32Value();
@@ -348,7 +556,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -348,7 +589,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();
@@ -1139,7 +1323,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
// Fetch pty handle from ID and start process
pty_baton* handle = get_pty_baton(id);
@@ -391,11 +600,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -391,11 +633,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
SIZE_T size = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &size);
@@ -1157,7 +1341,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
throw errorWithCode(info, "InitializeProcThreadAttributeList failed");
}
fSuccess = UpdateProcThreadAttribute(siEx.lpAttributeList,
@@ -406,36 +619,80 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -406,38 +652,82 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
NULL,
NULL);
if (!fSuccess) {
@@ -1220,7 +1404,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ cleanup_failed_connect(handle, &piClient, siEx.lpAttributeList);
+ SetLastError(jobError);
+ throw errorWithCode(info, "Cannot assign process to Job");
}
+ }
+ handle->hJob = hJob;
+
+ DWORD resumeResult;
@@ -1239,17 +1423,19 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ cleanup_failed_connect(handle, &piClient, siEx.lpAttributeList);
+ SetLastError(resumeError);
+ throw errorWithCode(info, "Cannot resume process");
+ }
+ }
+
}
}
+ DeleteProcThreadAttributeList(siEx.lpAttributeList);
+
+ if (handle->releasePseudoConsole != nullptr) {
+ handle->releasePseudoConsole(handle->hpc);
}
+ }
+
// Update handle
@@ -445,7 +702,9 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
handle->hShell = piClient.hProcess;
@@ -445,7 +735,9 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
CloseHandle(piClient.hThread);
// Close the input read and output write handle of the pseudoconsole
CloseHandle(handle->hIn);
@@ -1259,7 +1445,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
SetupExitCallback(env, exitCallback, handle);
@@ -544,21 +803,10 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
@@ -544,21 +836,10 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
@@ -1283,7 +1469,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
if (useConptyDll) {
TerminateProcess(handle->hShell, 1);
}
@@ -567,6 +815,109 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
@@ -567,6 +848,178 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
return env.Undefined();
}
@@ -1291,6 +1477,50 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ bool drained = false;
+};
+
+struct JobTerminationWorker {
+ std::thread thread;
+ JobTerminationResult result;
+};
+
+static void close_termination_job_handle(HANDLE handle) {
+ if (handle == nullptr) {
+ return;
+ }
+ CloseHandle(handle);
+#ifdef NODE_PTY_TEST_FAULTS
+ InterlockedIncrement(&closedTerminationJobHandleCount);
+#endif
+}
+
+class ScopedTerminationJobHandle {
+ public:
+ explicit ScopedTerminationJobHandle(HANDLE handle) : handle(handle) {}
+ ~ScopedTerminationJobHandle() {
+ close_termination_job_handle(handle);
+ }
+ ScopedTerminationJobHandle(const ScopedTerminationJobHandle&) = delete;
+ ScopedTerminationJobHandle& operator=(const ScopedTerminationJobHandle&) = delete;
+ HANDLE get() const { return handle; }
+ HANDLE release() {
+ const HANDLE released = handle;
+ handle = nullptr;
+ return released;
+ }
+
+ private:
+ HANDLE handle;
+};
+
+static void release_termination_worker(JobTerminationWorker* worker) {
+ if (worker->thread.joinable()) {
+ worker->thread.join();
+ }
+ delete worker;
+#ifdef NODE_PTY_TEST_FAULTS
+ InterlockedIncrement(&releasedTerminationThreadCount);
+#endif
+}
+
+static Napi::Value PtyTerminateJobTree(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
@@ -1321,71 +1551,96 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
+ DUPLICATE_SAME_ACCESS)) {
+ return env.Undefined();
+ }
+ if (!TerminateJobObject(hJob, 1)) {
+ CloseHandle(hJob);
+ ScopedTerminationJobHandle jobHandle(hJob);
+ if (!TerminateJobObject(jobHandle.get(), 1)) {
+ 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;
+#ifdef NODE_PTY_TEST_FAULTS
+ if (terminateJobFailurePoint == TerminateJobFailurePoint::ThreadAllocation) {
+ throw Napi::Error::New(env, "Injected termination thread allocation failure");
+ }
+#endif
+ auto worker = std::unique_ptr<JobTerminationWorker, decltype(&release_termination_worker)>(
+ new JobTerminationWorker,
+ release_termination_worker);
+#ifdef NODE_PTY_TEST_FAULTS
+ InterlockedIncrement(&allocatedTerminationThreadCount);
+ if (terminateJobFailurePoint == TerminateJobFailurePoint::ThreadSafeFunctionCreation) {
+ throw Napi::Error::New(env, "Injected termination ThreadSafeFunction failure");
+ }
+#endif
+ JobTerminationWorker *workerContext = worker.get();
+ auto tsfn = Napi::ThreadSafeFunction::New(
+ env,
+ Napi::Function::New(env, [](const Napi::CallbackInfo&) {}),
+ "PtyTerminateJobTree_resource",
+ 0,
+ 1,
+ [th](Napi::Env) {
+ th->join();
+ delete th;
+ });
+ [workerContext](Napi::Env) { release_termination_worker(workerContext); });
+ const napi_threadsafe_function rawTsfn = tsfn;
+ 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");
+ try {
+ workerContext->thread = std::thread([
+ rawTsfn,
+ deferred,
+ workerJob = jobHandle.get(),
+ workerContext,
+ timeoutMs] {
+ Napi::ThreadSafeFunction workerTsfn(rawTsfn);
+ ScopedTerminationJobHandle workerJobHandle(workerJob);
+ const ULONGLONG startedAt = GetTickCount64();
+ while (true) {
+ JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting{};
+ if (!QueryInformationJobObject(
+ workerJobHandle.get(),
+ JobObjectBasicAccountingInformation,
+ &accounting,
+ sizeof(accounting),
+ nullptr)) {
+ break;
+ }
+ break;
+ default:
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.BlockingCall() failed");
+ if (accounting.ActiveProcesses == 0) {
+ workerContext->result.drained = true;
+ break;
+ }
+ if (GetTickCount64() - startedAt >= timeoutMs) {
+ break;
+ }
+ Sleep(25);
+ }
+ const auto status = workerTsfn.BlockingCall(
+ &workerContext->result,
+ [deferred](Napi::Env env, Napi::Function, JobTerminationResult *result) {
+ deferred.Resolve(Napi::Boolean::New(env, result->drained));
+ });
+ switch (status) {
+ case napi_closing:
+ break;
+ case napi_queue_full:
+ Napi::Error::Fatal("PtyTerminateJobTree", "Queue was full");
+ case napi_ok:
+ if (workerTsfn.Release() != napi_ok) {
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.Release() failed");
+ }
+ break;
+ default:
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.BlockingCall() failed");
+ }
+ });
+ } catch (...) {
+ // Why: once TSFN creation succeeds, its finalizer owns the thread allocation.
+ worker.release();
+ if (tsfn.Release() != napi_ok) {
+ Napi::Error::Fatal("PtyTerminateJobTree", "ThreadSafeFunction.Release() failed");
+ }
+ });
+ throw;
+ }
+ jobHandle.release();
+ worker.release();
+
+ return deferred.Promise();
+}
@@ -1393,13 +1648,16 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4ac5716a6678e8ccfdd92ffe1f6529f2
/**
* Init
*/
@@ -577,6 +928,11 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
@@ -577,6 +1030,14 @@ 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));
+#ifdef NODE_PTY_TEST_FAULTS
+ exports.Set("setTestConnectFailure", Napi::Function::New(env, PtySetTestConnectFailure));
+ exports.Set(
+ "setTestTerminateJobFailure",
+ Napi::Function::New(env, PtySetTestTerminateJobFailure));
+ exports.Set("getTestResourceState", Napi::Function::New(env, PtyGetTestResourceState));
+#endif
return exports;
@@ -1449,7 +1707,7 @@ index dc2104b34fd396722a9e30821ff0b7dea18b4295..23510c3d7c85ecc911b94719832abe5f
describe('Plain strings', () => {
it('doesn\'t quote plain string', () => {
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483478bd331 100644
index d7054449516f0c9a62af351c2caa17331206d530..96deaf4fe037a983a80dfa8d9e6149aab5ea6a27 100644
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -34,6 +34,9 @@ export class WindowsPtyAgent {
@@ -1470,7 +1728,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
constructor(
file: string,
@@ -56,11 +60,14 @@ export class WindowsPtyAgent {
@@ -56,11 +60,17 @@ export class WindowsPtyAgent {
debug: boolean,
private _useConpty: boolean | undefined,
private _useConptyDll: boolean = false,
@@ -1481,12 +1739,15 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
if (this._useConpty === undefined || this._useConpty === true) {
this._useConpty = this._getWindowsBuildNumber() >= 18309;
}
+ // Why: a requested Job is effective only when runtime detection actually selects ConPTY.
+ this._windowsAgentJob = Boolean(this._useConpty && windowsAgentJob);
+ // Why: winpty cannot provide spawn-time Job ownership; launching anyway can orphan agents.
+ if (windowsAgentJob && !this._useConpty) {
+ throw new Error('Windows agent Job ownership requires ConPTY');
+ }
+ this._windowsAgentJob = windowsAgentJob;
if (this._useConpty) {
if (!conptyNative) {
conptyNative = loadNativeModule('conpty').module;
@@ -100,7 +107,7 @@ export class WindowsPtyAgent {
@@ -100,7 +110,7 @@ export class WindowsPtyAgent {
this._outSocket.setEncoding('utf8');
// The conout socket must be ready out on another thread to avoid deadlocks
this._conoutSocketWorker = new ConoutConnection(term.conout, this._useConptyDll);
@@ -1495,7 +1756,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
this._conoutSocketWorker.connectSocket(this._outSocket);
});
this._outSocket.on('connect', () => {
@@ -116,8 +123,18 @@ export class WindowsPtyAgent {
@@ -116,8 +126,18 @@ export class WindowsPtyAgent {
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
@@ -1516,7 +1777,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
}
}
@@ -138,6 +155,24 @@ export class WindowsPtyAgent {
@@ -138,6 +158,24 @@ export class WindowsPtyAgent {
}
}
@@ -1541,7 +1802,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
public kill(): void {
// Tell the agent to kill the pty, this releases handles to the process
if (this._useConpty) {
@@ -223,6 +258,10 @@ export class WindowsPtyAgent {
@@ -223,6 +261,10 @@ export class WindowsPtyAgent {
*/
private _$onProcessExit(exitCode: number): void {
this._exitCode = exitCode;
@@ -1553,24 +1814,22 @@ index d7054449516f0c9a62af351c2caa17331206d530..9742be105e477384855daa2f7d9a1483
this._flushDataAndCleanUp();
this._outSocket.on('data', () => this._flushDataAndCleanUp());
diff --git a/src/windowsTerminal.test.ts b/src/windowsTerminal.test.ts
index 8f1274edf6dcf6e9257ba9d711099791391ec778..afc5813d26ce0027b826d326fffdbc3c242753aa 100644
index 8f1274edf6dcf6e9257ba9d711099791391ec778..8c256e56c8c344584fc5b9ef8e670691a7feba25 100644
--- a/src/windowsTerminal.test.ts
+++ b/src/windowsTerminal.test.ts
@@ -92,6 +92,20 @@ function pollForProcessTreeSize(pid: number, size: number, intervalMs: number =
@@ -92,6 +92,18 @@ function pollForProcessTreeSize(pid: number, size: number, intervalMs: number =
}
if (process.platform === 'win32') {
+ describe('WindowsTerminal Job capability fallback', () => {
+ it('does not wait for a Job callback when a requested Job falls back to winpty', function(done) {
+ this.timeout(10000);
+ const term = new WindowsTerminal('cmd.exe', '/C exit', {
+ useConpty: false,
+ windowsAgentJob: true
+ });
+ term.on('exit', code => {
+ assert.strictEqual(code, 0);
+ done();
+ });
+ it('fails closed before a requested Job can fall back to winpty', () => {
+ assert.throws(
+ () => new WindowsTerminal('cmd.exe', '/C exit', {
+ useConpty: false,
+ windowsAgentJob: true
+ }),
+ /Windows agent Job ownership requires ConPTY/
+ );
+ });
+ });
+
+24 -26
View File
@@ -712,19 +712,17 @@
"ssh",
"runtime"
],
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Windows 2022 Actions red/green runs prove production store.removeWorktree(id, true) retires an agent PTY and its reparented native lock-holder before deleting the worktree, plain terminal close preserves an intentionally detached child, and natural agent-root exit publishes PTY exit only after its detached Job child is absent. Deterministic tests cover explicit and spawn-recognized native Windows agent Job selection, daemon session identity propagation, fail-closed Job teardown and bridge errors, safe failure when Job ownership is unavailable, adopted-v24 unclassified-shutdown refusal with known-WSL compatibility and renderer-state preservation, provider-scoped recovered routing, coalesced authoritative shutdown inventory and cached fallback/daemon duplicate refusal, duplicate-exit suppression, sleeping legacy ownership, daemon WSL exclusion, daemon-wide shutdown, remote-host Windows SSH relay Job selection/drain with WSL exclusion, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, WSL, end-to-end SSH transport, and remote-runtime process evidence remains pending.",
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Windows 2022 Actions red/green runs prove production store.removeWorktree(id, true) retires an agent PTY and its reparented native lock-holder before deleting the worktree, plain terminal close preserves an intentionally detached child, and natural agent-root exit publishes PTY exit only after its detached Job child is absent. Deterministic tests cover explicit and spawn-recognized native Windows agent Job selection, daemon session identity propagation, fail-closed Job teardown and bridge errors, safe failure when Job ownership is unavailable, adopted-v24 unclassified-shutdown refusal with known-WSL compatibility and renderer-state preservation, provider-scoped recovered routing, coalesced authoritative shutdown inventory and cached fallback/daemon duplicate refusal, duplicate-exit suppression, sleeping legacy ownership, daemon WSL exclusion, daemon-wide shutdown, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, WSL, Windows SSH, other end-to-end SSH transport, and remote-runtime process evidence remains pending.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8628",
"https://github.com/stablyai/orca/pull/8706",
"https://github.com/stablyai/orca/issues/9045"
],
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a native Windows agent PTY owns its spawn-time process family in a kill-on-close Job that must drain before destructive worktree deletion succeeds, including agents hosted by a Windows SSH relay, while plain terminals, WSL, POSIX SSH, and remote runtimes retain their existing semantics; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward. On Windows, prove the fixture is Git-registered, launch PowerShell shell -> agent Node process -> exited launcher -> reparented native lock-holder, invoke production store.removeWorktree(id, true), and prove the exact PTY, root, agent, lock-holder, worktree directory, Git registration, and worktree list entry are absent; separately prove a plain terminal's intentionally detached child survives close. A natural-root-exit journey records the public PTY exit callback and requires the detached Job child to be absent at that exact callback. A real Windows relay/node-pty integration holds a delete-denying file handle in a detached PowerShell child and requires remote-host Job drain before worktree deletion. Unit tests require agent-only spawn-time Job selection, handle-scoped local and Windows-relay Job termination before ConPTY close, daemon root signalling before Job termination and drain, fail-closed timeout/rejection/synchronous-throw behavior, adopted-v24 destructive-shutdown refusal without renderer-state loss, provider-scoped legacy/fallback route refresh, duplicate ownership refusal and exit suppression, local and relay WSL exclusion, safe failure without PID-only fallback when Job ownership is unavailable, daemon-wide routing through the same teardown, naturally exited id reservation, bounded locale-stable process-table reads, duplicate completion sharing, canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a native local Windows agent PTY owns its spawn-time process family in a kill-on-close Job that must drain before destructive worktree deletion succeeds, while plain terminals, WSL, SSH, and remote runtimes retain their existing semantics; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward. On Windows, prove the fixture is Git-registered, launch PowerShell shell -> agent Node process -> exited launcher -> reparented native lock-holder, invoke production store.removeWorktree(id, true), and prove the exact PTY, root, agent, lock-holder, worktree directory, Git registration, and worktree list entry are absent; separately prove a plain terminal's intentionally detached child survives close. A natural-root-exit journey records the public PTY exit callback and requires the detached Job child to be absent at that exact callback. Unit tests require agent-only spawn-time Job selection, handle-scoped local Job termination before ConPTY close, daemon root signalling before Job termination and drain, fail-closed timeout/rejection/synchronous-throw behavior, adopted-v24 destructive-shutdown refusal without renderer-state loss, provider-scoped legacy/fallback route refresh, duplicate ownership refusal and exit suppression, local WSL exclusion, safe failure without PID-only fallback when Job ownership is unavailable, daemon-wide routing through the same teardown, naturally exited id reservation, bounded locale-stable process-table reads, duplicate completion sharing, canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"commands": [
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-pty-session-routing.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/pty-subprocess.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/relay/pty-handler.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/relay/windows-agent-job.integration.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/windows-agent-job-natural-exit.spec.ts --workers=1",
@@ -747,13 +745,12 @@
"src/main/daemon/pty-subprocess.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/runtime/worktree-teardown.test.ts",
"src/relay/pty-handler.test.ts",
"src/relay/windows-agent-job.integration.test.ts",
"src/renderer/src/store/slices/agent-pane-authority.test.ts",
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts",
"tests/e2e/windows-agent-job-natural-exit.spec.ts"
@@ -854,23 +851,23 @@
"inventories above 32 sessions never exceed 32 concurrent provider shutdowns"
]
},
{
"file": "src/relay/pty-handler.test.ts",
"assertions": [
"native Windows SSH relay agents request remote-host Job ownership, drain before acknowledgement, and retain PTY ownership when drain fails",
"Windows SSH relay WSL agents do not request native Job ownership"
]
},
{
"file": "src/relay/windows-agent-job.integration.test.ts",
"assertions": [
"real Windows relay/node-pty shutdown kills a detached PowerShell lock-holder and releases its delete-denying handle before acknowledgement"
]
},
{
"file": "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"assertions": [
"unsafe adopted-v24 close and sleep keep the renderer tab and exact PTY mapping intact without issuing root-only kill"
"unsafe adopted-v24 close and sleep keep the renderer tab and exact PTY mapping intact without issuing root-only kill",
"async close safety replans transferred and replacement PTY ownership before retiring the current session"
]
},
{
"file": "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"assertions": [
"kill-all preflights unsafe legacy sessions before daemon shutdown and preserves their tabs and PTY mappings"
]
},
{
"file": "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts",
"assertions": [
"sleep leaves active-worktree, browser, and sleep-intent state untouched until terminal shutdown safety succeeds"
]
},
{
@@ -959,7 +956,7 @@
"command": "gh workflow run windows-terminal-restart-e2e.yml --ref OrcaWin/issue-9045-worktree-deletion-fails",
"result": "passed",
"durationSeconds": 555,
"summary": "Exact-head workflow run https://github.com/stablyai/orca/actions/runs/29766056882 checked out 1d04fbbc5, completed frozen install, passed the real Windows SSH relay/node-pty lock-holder teardown, verified generated conpty, conpty_console_list, winpty-agent, and winpty projects retained Spectre mitigation, rebuilt Electron, passed native failed-connect cleanup, and passed all six selected Electron regressions including production issue #9045 deletion, plain-terminal descendant preservation, and natural Job-drain exit ordering."
"summary": "Exact-head workflow run https://github.com/stablyai/orca/actions/runs/29766056882 checked out 1d04fbbc5, completed frozen install, verified generated conpty, conpty_console_list, winpty-agent, and winpty projects retained Spectre mitigation, rebuilt Electron, passed native failed-connect cleanup, and passed all six selected Electron regressions including production issue #9045 deletion, plain-terminal descendant preservation, and natural Job-drain exit ordering. The run's in-process relay check used the checkout dependency rather than the deployed SSH package and is not counted as production SSH evidence."
}
],
"runtimeBudget": {
@@ -968,7 +965,7 @@
},
"flakeHistory": {
"status": "unknown",
"evidence": "The macOS Electron gate passed three times locally. Five recorded Windows implementation heads passed the selected Electron gate on GitHub Actions; exact head 1d04fbbc5 also passed frozen install, the real Windows relay lock-holder integration, generated-project Spectre assertions, both native failed-connect cleanup cases, and all six Electron tests including the strengthened deletion and natural-exit oracles. Required-platform soak history is not yet available."
"evidence": "The macOS Electron gate passed three times locally. Five recorded Windows implementation heads passed the selected Electron gate on GitHub Actions; exact head 1d04fbbc5 also passed frozen install, generated-project Spectre assertions, both native failed-connect cleanup cases, and all six Electron tests including the strengthened deletion and natural-exit oracles. Required-platform soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
@@ -984,12 +981,13 @@
"Add restart/no-resurrection and repeated park-close soak coverage."
],
"knownGaps": [
"Live process proof covers macOS local PTYs, native Windows agent/plain PTYs, and the Windows relay with a real node-pty child, but not Linux, WSL, end-to-end SSH transport, or remote-runtime PTYs.",
"Live process proof covers macOS local PTYs and native Windows agent/plain PTYs, but not Linux, WSL, end-to-end SSH transport, or remote-runtime PTYs.",
"Disconnected SSH relay death still requires reconnect-aware provider ownership.",
"Daemon owner leases and durable retry inventory remain follow-up hardening.",
"Existing adopted Windows v24 native or unclassified sessions lack spawn-time Job capability metadata, so ordinary close, sleep, and deadline-bearing worktree deletion fail closed while the renderer keeps the tab and PTY ownership visible. Exiting the agent or shell may release them; otherwise users may need Task Manager or a Windows restart. A v24 reattach is treated as WSL only when the daemon reports WSL metadata; attached known-WSL sessions remain compatible. Fresh v25 plain and WSL behavior is unchanged.",
"Windows builds below the ConPTY capability boundary reject Orca-launched native agent sessions instead of silently falling back to an unowned winpty process tree; plain terminals retain their existing fallback.",
"An agent started manually after an ordinary native Windows terminal has spawned remains a plain terminal: it is not retroactively placed in Orca's agent Job, so detached descendants are preserved and may still block deletion. The Job-backed guarantee applies to Orca-launched or spawn-recognized native Windows agent sessions.",
"Fresh native Windows SSH relay agents use a Job on the remote Windows host; POSIX SSH, WSL, and remote-runtime PTYs retain their host-specific teardown paths and are not placed in the desktop's local Windows Job.",
"Windows SSH relays install stock node-pty on the remote host, so this local native-Job fix does not change their prior descendant-teardown behavior. Shipping a patched remote native artifact needs separate production packaging and end-to-end evidence.",
"A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.",
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot."
],
+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: d4382e83147336cfe41bd6174e27ae922723cab8da1da6939d16c74b71a46820
hash: 5d0ae74941b8c32662366208d63a2500fb3d5ac8802ea9552d0ca6d7c346e90c
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=d4382e83147336cfe41bd6174e27ae922723cab8da1da6939d16c74b71a46820)
version: 1.1.0(patch_hash=5d0ae74941b8c32662366208d63a2500fb3d5ac8802ea9552d0ca6d7c346e90c)
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=d4382e83147336cfe41bd6174e27ae922723cab8da1da6939d16c74b71a46820):
node-pty@1.1.0(patch_hash=5d0ae74941b8c32662366208d63a2500fb3d5ac8802ea9552d0ca6d7c346e90c):
dependencies:
node-addon-api: 7.1.1
-77
View File
@@ -11,7 +11,6 @@ import {
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
import { WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS } from '../main/windows-pty-job-control'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -1351,82 +1350,6 @@ describe('PtyHandler', () => {
})
})
it('Job-owns and drains a native Windows SSH relay agent', async () => {
await withWindowsPlatform(async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
const order: string[] = []
const terminateJobTree = vi.fn(async () => {
order.push('job')
return true
})
const mockKill = vi.fn(() => {
order.push('conpty')
onExitCb?.({ exitCode: 137 })
})
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
terminateJobTree,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
await dispatcher.callRequest('pty.spawn', {
shellOverride: 'powershell.exe',
launchAgent: 'claude'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
expect.stringMatching(/powershell/i),
expect.any(Array),
expect.objectContaining({ windowsAgentJob: true })
)
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
expect(terminateJobTree).toHaveBeenCalledWith(WINDOWS_PTY_JOB_DRAIN_TIMEOUT_MS)
expect(order).toEqual(['job', 'conpty'])
})
})
it('rejects and retains relay ownership when a Windows SSH agent Job does not drain', async () => {
await withWindowsPlatform(async () => {
const mockKill = vi.fn()
const terminateJobTree = vi.fn(async () => false)
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
kill: mockKill,
terminateJobTree,
onData: vi.fn(),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {
shellOverride: 'powershell.exe',
launchAgent: 'claude'
})
await expect(
dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
).rejects.toThrow('Windows PTY Job did not drain')
expectBareKills(mockKill, 1)
expect(handler.activePtyCount).toBe(1)
})
})
it('does not Job-own WSL agents on a Windows SSH relay', async () => {
await withWindowsPlatform(async () => {
await dispatcher.callRequest('pty.spawn', {
shellOverride: 'wsl.exe',
launchAgent: 'claude'
})
const spawnOptions = mockPtySpawn.mock.calls[0]?.[2]
expect(spawnOptions).not.toHaveProperty('windowsAgentJob')
})
})
it('does not double-kill when immediate cleanup joins graceful shutdown', async () => {
await withWindowsPlatform(async () => {
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
+2 -26
View File
@@ -39,9 +39,6 @@ import {
} from '../shared/git-credential-prompt-env'
import { isTuiAgent } from '../shared/tui-agent-config'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
import { terminateWindowsPtyTree } from '../main/windows-pty-job-control'
import { isWslShellName } from '../shared/local-windows-terminal-runtime'
import { recognizeAgentProcessFromCommandLine } from '../shared/agent-process-recognition'
import {
PTY_STARTUP_INGRESS_VERSION,
PtyStartupIngress,
@@ -87,7 +84,6 @@ type ManagedPty = {
physicalExit?: PhysicalExitTracker
forceKillSent?: boolean
gracefulKillSent?: boolean
windowsAgentJob?: boolean
startupIngress?: PtyStartupIngress
startupIngressIntent?: ReturnType<typeof parsePtyStartupIngressIntent>
}
@@ -928,13 +924,6 @@ export class PtyHandler {
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
const windowsAgentJob =
process.platform === 'win32' &&
!isWslShellName(shell) &&
Boolean(
isTuiAgent(params.launchAgent) ||
recognizeAgentProcessFromCommandLine(launchCommandHint)?.agent
)
// Why: SSH PTYs bypass main's host-env builder. Apply the policy only
// after the relay merges its authoritative process environment so indexed
// Git config and remote Windows/WSL behavior remain intact.
@@ -973,8 +962,7 @@ export class PtyHandler {
cwd,
// Why: relay shells inherit process.env; never let an ambient Orca marker
// enable shell-ready behavior unless this spawn explicitly requested it.
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env },
...(windowsAgentJob ? { windowsAgentJob: true } : {})
env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
} catch (error) {
// Why: Windows node-pty loads conpty.node only on first spawn, after the
@@ -1014,7 +1002,6 @@ export class PtyHandler {
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
...(windowsAgentJob ? { windowsAgentJob: true } : {}),
...(startupIngressIntent ? { startupIngressIntent } : {}),
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
@@ -1156,18 +1143,7 @@ export class PtyHandler {
return
}
if (process.platform === 'win32' && managed.windowsAgentJob) {
this.releaseStartupCommand(managed)
if (immediate) {
this.flushPtyOutput(id)
}
// Why: the SSH relay is the native host on Windows; it must drain its Job
// before acknowledging close to a client that may delete the worktree.
await terminateWindowsPtyTree(managed.pty, () => this.requestForceKill(managed))
if (immediate) {
await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS)
}
} else if (immediate) {
if (immediate) {
this.releaseStartupCommand(managed)
this.flushPtyOutput(id)
this.requestForceKill(managed)
@@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { RelayDispatcher } from './dispatcher'
import { PtyHandler } from './pty-handler'
function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
async function waitFor(predicate: () => boolean, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('Timed out waiting for Windows relay process evidence')
}
describe.skipIf(process.platform !== 'win32')('Windows relay agent Job integration', () => {
let rootDir: string
let dispatcher: RelayDispatcher
let handler: PtyHandler
let mux: SshChannelMultiplexer
let lockHolderPid: number | null
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), 'orca-relay-job-'))
lockHolderPid = null
let feedRelay: (data: Buffer) => void
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data) => setImmediate(() => feedRelay(data)),
onData: (callback) => clientDataCallbacks.push(callback),
onClose: () => {}
}
dispatcher = new RelayDispatcher((data) => {
setImmediate(() => clientDataCallbacks.forEach((callback) => callback(data)))
})
feedRelay = (data) => dispatcher.feed(data)
handler = new PtyHandler(dispatcher)
mux = new SshChannelMultiplexer(clientTransport)
})
afterEach(async () => {
mux.dispose()
dispatcher.dispose()
await handler.dispose({ waitForPhysicalExit: false }).catch(() => {})
if (lockHolderPid) {
try {
execFileSync('taskkill.exe', ['/PID', String(lockHolderPid), '/T', '/F'], {
stdio: 'ignore'
})
} catch {
/* already gone */
}
}
rmSync(rootDir, { recursive: true, force: true })
})
it('drains a detached lock-holder before acknowledging remote shutdown', async () => {
const worktreeDir = join(rootDir, 'worktree')
const lockPath = join(worktreeDir, 'held.lock')
const pidPath = join(rootDir, 'lock-holder.pid')
const childScriptPath = join(rootDir, 'hold-lock.ps1')
const rootScriptPath = join(rootDir, 'launch-child.ps1')
mkdirSync(worktreeDir)
writeFileSync(
childScriptPath,
[
'param([string]$LockPath, [string]$PidPath)',
"$stream = [IO.File]::Open($LockPath, 'OpenOrCreate', 'ReadWrite', 'Read')",
'[IO.File]::WriteAllText($PidPath, [string]$PID)',
'try { while ($true) { Start-Sleep -Milliseconds 250 } } finally { $stream.Dispose() }'
].join('\r\n')
)
writeFileSync(
rootScriptPath,
[
'param([string]$ChildScript, [string]$LockPath, [string]$PidPath)',
'$childArgs = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$ChildScript`" -LockPath `"$LockPath`" -PidPath `"$PidPath`""',
'$child = Start-Process powershell.exe -WindowStyle Hidden -PassThru -ArgumentList $childArgs',
'while (-not (Test-Path -LiteralPath $PidPath)) { Start-Sleep -Milliseconds 50 }',
'while ($true) { Start-Sleep -Milliseconds 250 }'
].join('\r\n')
)
writeFileSync(lockPath, '')
const command =
`& ${quotePowerShell(rootScriptPath)}` +
` -ChildScript ${quotePowerShell(childScriptPath)}` +
` -LockPath ${quotePowerShell(lockPath)}` +
` -PidPath ${quotePowerShell(pidPath)}`
const spawned = (await mux.request('pty.spawn', {
cols: 80,
rows: 24,
cwd: rootDir,
shellOverride: 'powershell.exe',
command,
commandDelivery: 'provider',
launchAgent: 'claude'
})) as { id: string }
await waitFor(() => existsSync(pidPath))
lockHolderPid = Number(readFileSync(pidPath, 'utf8').trim())
expect(lockHolderPid).toBeGreaterThan(0)
// Why: prove the child holds a real Windows delete-denying handle before teardown.
expect(() => rmSync(lockPath, { force: true })).toThrow()
await mux.request('pty.shutdown', { id: spawned.id, immediate: true })
await waitFor(() => {
try {
process.kill(lockHolderPid!, 0)
return false
} catch {
return true
}
})
rmSync(worktreeDir, { recursive: true, force: true })
expect(existsSync(worktreeDir)).toBe(false)
lockHolderPid = null
}, 30_000)
})
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON } from '../../../../shared/pty-shutdown-safety'
import { useAppStore, type AppState } from '@/store'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import {
@@ -100,6 +101,81 @@ describe('snapshotKillAllTerminalSurfaceIds', () => {
})
describe('runKillAllTerminalSurfaces', () => {
it('preserves unsafe legacy tabs and mappings before daemon kill-all starts', async () => {
const current = state({
tabsByWorktree: {
wt: [{ ...terminal('legacy-target', 'wt'), ptyId: 'pty-v24' }]
},
ptyIdsByTabId: { 'legacy-target': ['pty-v24'] }
})
const getShutdownBlockReason = vi
.fn()
.mockResolvedValue(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
const killDaemonSessions = vi.fn()
const closeSurface = vi.fn()
const killPty = vi.fn()
vi.stubGlobal('window', { api: { pty: { getShutdownBlockReason } } })
try {
await expect(
runKillAllTerminalSurfaces(['legacy-target'], {
getState: () => current,
killDaemonSessions,
closeSurface,
killPty,
reportSummary: vi.fn()
})
).rejects.toThrow(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
expect(killDaemonSessions).not.toHaveBeenCalled()
expect(closeSurface).not.toHaveBeenCalled()
expect(killPty).not.toHaveBeenCalled()
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['legacy-target'])
expect(current.ptyIdsByTabId['legacy-target']).toEqual(['pty-v24'])
} finally {
vi.unstubAllGlobals()
}
})
it('rechecks ownership after daemon management before retiring a surface', async () => {
const current = state({
tabsByWorktree: {
wt: [{ ...terminal('target', 'wt'), ptyId: 'pty-safe' }]
},
ptyIdsByTabId: { target: ['pty-safe'] }
})
const getShutdownBlockReason = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
const closeSurface = vi.fn()
vi.stubGlobal('window', { api: { pty: { getShutdownBlockReason } } })
try {
await expect(
runKillAllTerminalSurfaces(['target'], {
getState: () => current,
killDaemonSessions: vi.fn(async () => {
current.tabsByWorktree.wt = [{ ...terminal('target', 'wt'), ptyId: 'pty-v24' }]
current.ptyIdsByTabId.target = ['pty-v24']
return { killedCount: 0, remainingCount: 1 }
}),
closeSurface,
killPty: vi.fn(),
reportSummary: vi.fn()
})
).rejects.toThrow(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
expect(getShutdownBlockReason).toHaveBeenNthCalledWith(1, 'pty-safe')
expect(getShutdownBlockReason).toHaveBeenNthCalledWith(2, 'pty-v24')
expect(closeSurface).not.toHaveBeenCalled()
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['target'])
expect(current.ptyIdsByTabId.target).toEqual(['pty-v24'])
} finally {
vi.unstubAllGlobals()
}
})
it('resolves current bindings and ownership after management settles, then closes active last', async () => {
const management = deferred<{ killedCount: number; remainingCount: number }>()
const lastExactKill = deferred<void>()
@@ -9,6 +9,10 @@ import {
type TerminalTabRetirementState
} from '@/store/slices/terminal-tab-retirement'
import { reserveTerminalRetirementTeardowns } from '@/store/slices/terminal-retirement-teardown-reservation'
import {
assertStableTerminalShutdownSafety,
hasTerminalShutdownSafetyPreflight
} from '@/store/slices/terminal-shutdown-safety'
const CLOSE_BATCH_SIZE = 2
@@ -32,11 +36,7 @@ export type KillAllTerminalSurfacesSummary = {
maxCloseBatchDurationMs: number
closeYieldCount: number
closePhaseExceededLongTaskBudget: boolean
daemon:
| ({ status: 'fulfilled' } & DaemonKillAllResult)
| {
status: 'rejected'
}
daemon: ({ status: 'fulfilled' } & DaemonKillAllResult) | { status: 'rejected' }
}
type KillAllTerminalSurfaceDependencies = {
@@ -58,7 +58,7 @@ type KillAllTerminalSurfaceDependencies = {
}
export function snapshotKillAllTerminalSurfaceIds(
state: KillAllTerminalSurfaceState = useAppStore.getState()
state: TerminalTabRetirementState = useAppStore.getState()
): string[] {
const targetIds = new Set<string>()
for (const tabs of Object.values(state.tabsByWorktree)) {
@@ -151,6 +151,22 @@ export async function runKillAllTerminalSurfaces(
const deps = { ...createDefaultDependencies(), ...dependencies }
const targetIds = [...new Set(snapshotTargetIds)]
const shouldPreflightTargets = targetIds.length > 0 && hasTerminalShutdownSafetyPreflight()
const assertTargetsSafe = (): Promise<unknown> =>
assertStableTerminalShutdownSafety({
surfaceId: 'kill-all-terminal-surfaces',
getState: deps.getState,
selectTabIds: (state) => {
const presentIds = new Set(snapshotKillAllTerminalSurfaceIds(state))
return targetIds.filter((targetId) => presentIds.has(targetId))
}
})
// Why: management failure is recoverable only while every unsafe session remains visible.
if (shouldPreflightTargets) {
await assertTargetsSafe()
}
let daemon: KillAllTerminalSurfacesSummary['daemon']
try {
daemon = { status: 'fulfilled', ...(await deps.killDaemonSessions()) }
@@ -158,6 +174,11 @@ export async function runKillAllTerminalSurfaces(
daemon = { status: 'rejected' }
}
// Why: management is asynchronous; do not retire a legacy owner attached during its request.
if (shouldPreflightTargets) {
await assertTargetsSafe()
}
const cleanupState = deps.getState()
const remainingTargetIds = new Set(targetIds)
const createCloseWave = (state: KillAllTerminalSurfaceState) => {
@@ -172,8 +193,7 @@ export async function runKillAllTerminalSurfaces(
}
}
const activeWorktreeId = state.activeWorktreeId
// Why: keeping the active worktree until its targets close lets the existing
// tab action choose editor/browser/deactivation without spawning a replacement.
// Why: closing active-worktree targets last avoids spawning a replacement surface.
const closeOrder = [
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
@@ -233,8 +253,7 @@ export async function runKillAllTerminalSurfaces(
const failedTargetSurvived =
closeFailed && snapshotKillAllTerminalSurfaceIds(deps.getState()).includes(targetId)
if (failedTargetSurvived) {
// Why: a pre-mutation failure leaves the tab as a live non-target owner.
// Replan before touching siblings so it still protects counts and PTYs.
// Why: a surviving failed tab must keep protecting its counts and PTYs.
for (const owner of newlyScheduledPtyOwners) {
scheduledPtyOwners.delete(owner)
}
@@ -255,8 +274,7 @@ export async function runKillAllTerminalSurfaces(
}
maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
if (remainingTargetIds.size > 0) {
// Why: closeTab cascades clone several store maps, so large confirmed
// snapshots yield between bounded batches instead of monopolizing a frame.
// Why: bounded batches keep large close snapshots from monopolizing a frame.
const stateAfterBatch = deps.getState()
try {
await deps.yieldToRenderer()
@@ -267,16 +285,14 @@ export async function runKillAllTerminalSurfaces(
closeBatchStartedAt = deps.now()
const stateAfterYield = deps.getState()
if (mustReplanAfterYield || stateAfterYield !== stateAfterBatch) {
// Why: a yield lets tabs move, detach, or appear. Replanning the
// remaining snapshot prevents stale ownership from killing a survivor.
// Why: replanning after a mutable yield prevents killing a new PTY owner.
closeWave = createCloseWave(stateAfterYield)
}
}
}
const closeDurationMs = Math.max(0, deps.now() - closeStartedAt)
// Why: the management sweep already settled daemon-owned IDs; awaiting only
// reserved exact kills keeps each provider at one request per ownership identity.
// Why: daemon-owned IDs were settled, so only reserved exact kills remain.
const exactKillResults = await Promise.allSettled(exactKillTasks)
const exactKillAcceptedCount = exactKillResults.filter(
(result) => result.status === 'fulfilled'
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON } from '../../../../shared/pty-shutdown-safety'
const mocks = vi.hoisted(() => {
const state = {
@@ -9,7 +10,12 @@ const mocks = vi.hoisted(() => {
suppressPtyExit: vi.fn(),
consumeSuppressedPtyExit: vi.fn(),
tabsByWorktree: {} as Record<string, { id: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>
unifiedTabsByWorktree: {},
ptyIdsByTabId: {} as Record<string, string[]>,
terminalLayoutsByTabId: {},
lastKnownRelayPtyIdByTabId: {},
deferredSshSessionIdsByTabId: {},
pendingReconnectPtyIdByTabId: {}
}
const suspendWorkspace = vi.fn().mockResolvedValue(null)
const toastError = vi.fn()
@@ -60,7 +66,12 @@ describe('runSleepWorktree', () => {
mocks.toastError.mockClear()
mocks.state.activeWorktreeId = null
mocks.state.tabsByWorktree = {}
mocks.state.unifiedTabsByWorktree = {}
mocks.state.ptyIdsByTabId = {}
mocks.state.terminalLayoutsByTabId = {}
mocks.state.lastKnownRelayPtyIdByTabId = {}
mocks.state.deferredSshSessionIdsByTabId = {}
mocks.state.pendingReconnectPtyIdByTabId = {}
})
it('tears down browsers before terminals on the sleep path', async () => {
@@ -110,6 +121,43 @@ describe('runSleepWorktree', () => {
expect(terminalShutdown).toBeLessThan(clearCall)
})
it('preserves active and browser state until legacy terminal safety is known', async () => {
let resolvePreflight!: (reason: string | null) => void
const getShutdownBlockReason = vi.fn(
() =>
new Promise<string | null>((resolve) => {
resolvePreflight = resolve
})
)
vi.stubGlobal('window', {
api: {
pty: { getShutdownBlockReason },
ephemeralVm: { suspendWorkspace: mocks.suspendWorkspace }
},
requestAnimationFrame: vi.fn()
})
mocks.state.activeWorktreeId = 'wt-1'
mocks.state.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-v24'] }
const completion = runSleepWorktree('wt-1')
await vi.waitFor(() => expect(getShutdownBlockReason).toHaveBeenCalledWith('pty-v24'))
expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalled()
expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled()
expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled()
expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled()
resolvePreflight(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
await completion
expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalled()
expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled()
expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled()
expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled()
expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled()
})
it('preserves active row position through section-scoped sidebar row ids', async () => {
const requestAnimationFrame = vi.fn(() => 1)
const scroller = {
@@ -3,6 +3,10 @@ import { useAppStore } from '@/store'
import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
import { translate } from '@/i18n/i18n'
import {
assertStableTerminalShutdownSafety,
selectTerminalTabIdsForWorktrees
} from '@/store/slices/terminal-shutdown-safety'
/**
* Shared "sleep worktree" flow (close all panels to free memory / CPU)
@@ -110,6 +114,18 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
if (worktreeIds.length === 0) {
return
}
const worktreeIdSet = new Set(worktreeIds)
try {
// Why: a legacy refusal must leave activation, browsers, and sleep intent untouched.
await assertStableTerminalShutdownSafety({
surfaceId: worktreeIds.join(':'),
getState: useAppStore.getState,
selectTabIds: (state) => selectTerminalTabIdsForWorktrees(state, worktreeIdSet)
})
} catch {
// The safety preflight already explains why the workspace stayed awake.
return
}
const {
activeWorktreeId,
setActiveWorktree,
@@ -103,11 +103,8 @@ export function closeTerminalTab(
!options?.localPtyTeardownOwnedExternally &&
!options?.shutdownSafetyChecked &&
deferTerminalTabCloseForShutdownSafety({
state,
getState: useAppStore.getState,
tabId: terminalTabId,
...(options?.precomputedRetirementPlan
? { precomputedRetirementPlan: options.precomputedRetirementPlan }
: {}),
onAllowed: (retirementPlan) =>
closeTerminalTab(tabId, {
...options,
@@ -3,11 +3,18 @@ import { translate } from '@/i18n/i18n'
import { WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON } from '../../../../shared/pty-shutdown-safety'
import {
buildTerminalTabRetirementPlan,
buildTerminalTabRetirementPlans,
type TerminalTabRetirementPlan,
type TerminalTabRetirementState
} from './terminal-tab-retirement'
const pendingPreflights = new Set<string>()
const MAX_STABILITY_ATTEMPTS = 3
type StableTerminalShutdownPlan = {
plans: Map<string, TerminalTabRetirementPlan>
signature: string
}
function getShutdownSafetyPreflight():
| ((id: string) => ReturnType<Window['api']['pty']['getShutdownBlockReason']>)
@@ -82,13 +89,95 @@ export async function assertTerminalShutdownSafety(args: {
}
}
export function deferTerminalCloseForShutdownSafety(args: {
function buildStableTerminalShutdownPlan(
state: TerminalTabRetirementState,
tabIds: readonly string[]
): StableTerminalShutdownPlan {
const plans = buildTerminalTabRetirementPlans(state, tabIds)
const signature = JSON.stringify(
[...plans.values()].map((plan) => ({
tabId: plan.tabId,
worktreeId: plan.worktreeId,
localOrSshPtyIds: [...plan.localOrSshPtyIds].sort(),
runtimePtyIds: plan.runtimeTerminals.map((terminal) => terminal.ptyId).sort(),
sharedPtyIds: [...plan.sharedPtyIds].sort(),
cleanupOnlyPtyIds: [...plan.cleanupOnlyPtyIds].sort(),
unroutablePtyIds: [...plan.unroutablePtyIds].sort()
}))
)
return { plans, signature }
}
function getLocalOrSshPtyIds(plans: ReadonlyMap<string, TerminalTabRetirementPlan>): string[] {
return [...new Set([...plans.values()].flatMap((plan) => plan.localOrSshPtyIds))]
}
/**
* Rechecks renderer ownership after async main-process inventory. The returned
* plans are safe to consume synchronously in the same renderer turn.
*/
export async function assertStableTerminalShutdownSafety(args: {
surfaceId: string
getState: () => TerminalTabRetirementState
selectTabIds: (state: TerminalTabRetirementState) => readonly string[]
}): Promise<Map<string, TerminalTabRetirementPlan>> {
for (let attempt = 0; attempt < MAX_STABILITY_ATTEMPTS; attempt += 1) {
const beforeState = args.getState()
const before = buildStableTerminalShutdownPlan(beforeState, args.selectTabIds(beforeState))
await assertTerminalShutdownSafety({
surfaceId: args.surfaceId,
ptyIds: getLocalOrSshPtyIds(before.plans)
})
const currentState = args.getState()
const after = buildStableTerminalShutdownPlan(currentState, args.selectTabIds(currentState))
if (before.signature === after.signature) {
return after.plans
}
}
// Why: repeated ownership churn means no asynchronous answer still describes
// the tab state; fail closed instead of retiring a newly attached PTY.
surfacePreflightFailure(args.surfaceId)
throw new Error('Terminal ownership changed while shutdown safety was being verified')
}
export function selectTerminalTabIdsForWorktrees(
state: TerminalTabRetirementState,
worktreeIds: ReadonlySet<string>
): string[] {
const tabIds = new Set<string>()
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
if (!worktreeIds.has(worktreeId)) {
continue
}
for (const tab of tabs) {
tabIds.add(tab.id)
}
}
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
if (!worktreeIds.has(worktreeId)) {
continue
}
for (const tab of tabs) {
if (tab.contentType === 'terminal') {
tabIds.add(tab.entityId)
}
}
}
return [...tabIds]
}
export function deferTerminalTabCloseForShutdownSafety(args: {
getState: () => TerminalTabRetirementState
tabId: string
ptyIds: string[]
onAllowed: () => void
onAllowed: (plan: TerminalTabRetirementPlan) => void
onBlocked?: () => void
}): boolean {
if (!hasTerminalShutdownSafetyPreflight() || args.ptyIds.length === 0) {
if (!hasTerminalShutdownSafetyPreflight()) {
return false
}
const initialPlan = buildTerminalTabRetirementPlan(args.getState(), args.tabId)
if (initialPlan.localOrSshPtyIds.length === 0) {
return false
}
if (pendingPreflights.has(args.tabId)) {
@@ -96,10 +185,16 @@ export function deferTerminalCloseForShutdownSafety(args: {
}
pendingPreflights.add(args.tabId)
void assertTerminalShutdownSafety({ surfaceId: args.tabId, ptyIds: args.ptyIds }).then(
() => {
void assertStableTerminalShutdownSafety({
surfaceId: args.tabId,
getState: args.getState,
selectTabIds: () => [args.tabId]
}).then(
(plans) => {
pendingPreflights.delete(args.tabId)
args.onAllowed()
args.onAllowed(
plans.get(args.tabId) ?? buildTerminalTabRetirementPlan(args.getState(), args.tabId)
)
},
() => {
pendingPreflights.delete(args.tabId)
@@ -108,25 +203,3 @@ export function deferTerminalCloseForShutdownSafety(args: {
)
return true
}
export function deferTerminalTabCloseForShutdownSafety(args: {
state: TerminalTabRetirementState
tabId: string
precomputedRetirementPlan?: TerminalTabRetirementPlan
onAllowed: (plan: TerminalTabRetirementPlan) => void
onBlocked?: () => void
}): boolean {
if (!hasTerminalShutdownSafetyPreflight()) {
return false
}
const plan =
args.precomputedRetirementPlan?.tabId === args.tabId
? args.precomputedRetirementPlan
: buildTerminalTabRetirementPlan(args.state, args.tabId)
return deferTerminalCloseForShutdownSafety({
tabId: args.tabId,
ptyIds: plan.localOrSshPtyIds,
onAllowed: () => args.onAllowed(plan),
...(args.onBlocked ? { onBlocked: args.onBlocked } : {})
})
}
@@ -49,6 +49,14 @@ function sleepingRecord(paneKey: string, tabId: string): SleepingAgentSessionRec
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('terminal tab retirement store boundary', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -290,6 +298,42 @@ describe('terminal tab retirement store boundary', () => {
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['pty-v24'])
})
it('replans after async safety inventory before retiring current PTY ownership', async () => {
const store = createTestStore()
const firstPreflight = deferred<null>()
mockGetShutdownBlockReason.mockImplementationOnce(() => firstPreflight.promise)
seedStore(store, {
tabsByWorktree: {
'wt-1': [
makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-before' }),
makeTab({ id: 'survivor', worktreeId: 'wt-1', ptyId: null })
]
},
ptyIdsByTabId: { 'tab-1': ['pty-before'], survivor: [] }
})
store.getState().closeTab('tab-1')
expect(mockGetShutdownBlockReason).toHaveBeenCalledWith('pty-before')
store.setState({
tabsByWorktree: {
'wt-1': [
makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-after' }),
makeTab({ id: 'survivor', worktreeId: 'wt-1', ptyId: 'pty-before' })
]
},
ptyIdsByTabId: { 'tab-1': ['pty-after'], survivor: ['pty-before'] }
})
firstPreflight.resolve(null)
await vi.waitFor(() => expect(mockGetShutdownBlockReason).toHaveBeenCalledWith('pty-after'))
await vi.waitFor(() => expect(mockKill).toHaveBeenCalledWith('pty-after'))
expect(mockKill).not.toHaveBeenCalledWith('pty-before')
expect(store.getState().tabsByWorktree['wt-1'].map((tab) => tab.id)).toEqual(['survivor'])
expect(store.getState().ptyIdsByTabId.survivor).toEqual(['pty-before'])
})
it('keeps sleep state and PTY ownership intact when legacy shutdown is unsafe', async () => {
const store = createTestStore()
mockGetShutdownBlockReason.mockResolvedValueOnce(WINDOWS_LEGACY_PTY_SHUTDOWN_BLOCK_REASON)
+13 -10
View File
@@ -78,8 +78,9 @@ import {
} from '@/components/terminal-pane/terminal-layout-leaf-ids'
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
import {
assertTerminalShutdownSafety,
deferTerminalCloseForShutdownSafety
assertStableTerminalShutdownSafety,
deferTerminalTabCloseForShutdownSafety,
selectTerminalTabIdsForWorktrees
} from './terminal-shutdown-safety'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
@@ -1234,13 +1235,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
retiresSession &&
!opts?.localPtyTeardownOwnedExternally &&
!opts?.shutdownSafetyChecked &&
deferTerminalCloseForShutdownSafety({
deferTerminalTabCloseForShutdownSafety({
getState: get,
tabId,
ptyIds: retirementPlan.localOrSshPtyIds,
onAllowed: () =>
onAllowed: (currentRetirementPlan) =>
get().closeTab(tabId, {
...opts,
precomputedRetirementPlan: retirementPlan,
precomputedRetirementPlan: currentRetirementPlan,
shutdownSafetyChecked: true
})
})
@@ -2511,15 +2512,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const keepIdentifiers = opts?.keepIdentifiers ?? false
const shutdownReason: AgentStatusWorktreeShutdownReason =
opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree')
const worktreeIds = new Set([worktreeId])
await assertStableTerminalShutdownSafety({
surfaceId: worktreeId,
getState: get,
selectTabIds: (state) => selectTerminalTabIdsForWorktrees(state, worktreeIds)
})
const tabs = get().tabsByWorktree[worktreeId] ?? []
const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? [])
const rendererShutdownPtyIds = sortedUniquePtyIds(ptyIds)
const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds)
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
await assertTerminalShutdownSafety({
surfaceId: worktreeId,
ptyIds: rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:'))
})
// Why: expectedRuntimePtyIds are raw RPC handles. Only renderer-bound ids
// can emit pane exit callbacks, so they are the complete guard identity set.
const exitGuardPtyIds = rendererShutdownPtyIds