fix(windows): gate PTY exit on Job drain

This commit is contained in:
OrcaWin
2026-07-20 03:51:10 -07:00
parent f9af2ce8a5
commit 288116dfd5
10 changed files with 460 additions and 29 deletions
@@ -20,6 +20,7 @@ on:
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/agent-descendant-process-kill.spec.ts'
- 'tests/e2e/windows-agent-job-natural-exit.spec.ts'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
@@ -93,7 +94,8 @@ jobs:
tests/e2e/restart-restore-terminal-input.spec.ts
tests/e2e/terminal-restart-persistence.spec.ts
tests/e2e/agent-descendant-process-kill.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves|Windows worktree deletion releases|plain Windows terminal close preserves"
tests/e2e/windows-agent-job-natural-exit.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves|Windows worktree deletion releases|plain Windows terminal close preserves|natural Windows agent root exit waits"
--workers=1
- name: Upload Playwright traces
+132 -22
View File
@@ -1,5 +1,5 @@
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..6047590bc9558dabacd3ece5114ed41c76e0221f 100644
--- a/deps/winpty/src/winpty.gyp
+++ b/deps/winpty/src/winpty.gyp
@@ -10,7 +10,7 @@
@@ -21,7 +21,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
},
'targets' : [
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..201fc1d70a907e31b5a2b33b19a9db20803bc652 100644
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
@@ -42,7 +42,7 @@ index 8c4fca9022a6d6f015bca87f61625cde2278f428..201fc1d70a907e31b5a2b33b19a9db20
//# 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..3c4ae8e7818379a4f6f09ee740959292a2c3515f 100644
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -61,10 +61,10 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..3c4ae8e7818379a4f6f09ee740959292
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
index a358ffb177357e177661033c1b092f9c9d0e5f5a..acc4f5ac94f38db2184ba7d3da382c720ca7807a 100644
index a358ffb177357e177661033c1b092f9c9d0e5f5a..b34cfef73fcdcf7897b6f4646ec464f348311330 100644
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -26,10 +26,11 @@ var FLUSH_DATA_INTERVAL = 1000;
@@ -26,14 +26,17 @@ var FLUSH_DATA_INTERVAL = 1000;
* and winpty.
*/
var WindowsPtyAgent = /** @class */ (function () {
@@ -77,7 +77,13 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..acc4f5ac94f38db2184ba7d3da382c72
this._useConpty = _useConpty;
this._useConptyDll = _useConptyDll;
this._pid = 0;
@@ -86,7 +87,7 @@ var WindowsPtyAgent = /** @class */ (function () {
this._innerPid = 0;
+ this._processExited = false;
+ this._processExitListeners = [];
if (this._useConpty === undefined || this._useConpty === true) {
this._useConpty = this._getWindowsBuildNumber() >= 18309;
}
@@ -86,7 +89,7 @@ var WindowsPtyAgent = /** @class */ (function () {
});
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
@@ -86,7 +92,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..acc4f5ac94f38db2184ba7d3da382c72
this._innerPid = connect.pid;
}
}
@@ -130,6 +131,13 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -130,6 +133,20 @@ var WindowsPtyAgent = /** @class */ (function () {
this._ptyNative.clear(this._pty, this._useConptyDll);
}
};
@@ -96,24 +102,71 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..acc4f5ac94f38db2184ba7d3da382c72
+ }
+ var _a;
+ return (_a = this._ptyNative.terminateJobTree) === null || _a === void 0 ? void 0 : _a.call(this._ptyNative, this._pty, timeoutMs);
+ };
+ WindowsPtyAgent.prototype.onProcessExit = function (listener) {
+ if (this._processExited) {
+ listener();
+ return;
+ }
+ this._processExitListeners.push(listener);
+ };
WindowsPtyAgent.prototype.kill = function () {
var _this = this;
// Tell the agent to kill the pty, this releases handles to the process
@@ -221,6 +238,10 @@ var WindowsPtyAgent = /** @class */ (function () {
WindowsPtyAgent.prototype._$onProcessExit = function (exitCode) {
var _this = this;
this._exitCode = exitCode;
+ this._processExited = true;
+ var listeners = this._processExitListeners;
+ this._processExitListeners = [];
+ listeners.forEach(function (listener) { return listener(); });
if (!this._useConptyDll) {
this._flushDataAndCleanUp();
this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
index 3c38f89d14cece8081782a9600ebc582b7c2a62a..5790166246b1cbe6d33e0563b6498901dd80b1a9 100644
index 3c38f89d14cece8081782a9600ebc582b7c2a62a..7a488cbd891e0227b367857af894b4818a7a6031 100644
--- a/lib/windowsTerminal.js
+++ b/lib/windowsTerminal.js
@@ -48,7 +48,7 @@ var WindowsTerminal = /** @class */ (function (_super) {
@@ -48,8 +48,23 @@ 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;
+ var socketClosed = false;
+ var agentProcessExited = false;
+ var exitEmitted = false;
+ var emitExitWhenSafe = function () {
+ if (exitEmitted || !socketClosed || (opt.windowsAgentJob && !agentProcessExited)) {
+ return;
+ }
+ exitEmitted = true;
+ _this.emit('exit', _this._agent.exitCode);
+ _this._close();
+ };
+ _this._agent.onProcessExit(function () {
+ agentProcessExited = true;
+ emitExitWhenSafe();
+ });
// Not available until `ready` event emitted.
_this._pid = _this._agent.innerPid;
@@ -144,6 +144,9 @@ var WindowsTerminal = /** @class */ (function (_super) {
_this._fd = _this._agent.fd;
@@ -95,8 +110,10 @@ var WindowsTerminal = /** @class */ (function (_super) {
});
// Cleanup after the socket is closed.
_this._socket.on('close', function () {
- _this.emit('exit', _this._agent.exitCode);
- _this._close();
+ // Why: a Job-owned root can close ConPTY output before the native exit
+ // callback proves every descendant is gone; public exit needs both facts.
+ socketClosed = true;
+ emitExitWhenSafe();
});
});
_this._file = file;
@@ -144,6 +161,9 @@ var WindowsTerminal = /** @class */ (function (_super) {
_this.kill();
});
};
@@ -124,7 +177,7 @@ index 3c38f89d14cece8081782a9600ebc582b7c2a62a..5790166246b1cbe6d33e0563b6498901
var _this = this;
this._deferNoArgs(function () {
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..24519545c34e0f9a4438a814dd1feb9fd18517d1 100644
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
@@ -155,7 +208,7 @@ index a269e77bd2acf31c762b0fd8e13e6bf435fd7d07..adc2e8d10190de6bbeea1f2b53320ef9
export interface IPtyOpenOptions {
diff --git a/src/native.d.ts b/src/native.d.ts
index c53e086b4968d6fb0bf601ace3a813809433fedb..7d7ae975dabe17e5b9dadc3c8d09364fc438c421 100644
index c53e086b4968d6fb0bf601ace3a813809433fedb..ce2d197bbf618f17cdcbf1e6127e831715a8beb3 100644
--- a/src/native.d.ts
+++ b/src/native.d.ts
@@ -4,10 +4,11 @@
@@ -172,7 +225,7 @@ index c53e086b4968d6fb0bf601ace3a813809433fedb..7d7ae975dabe17e5b9dadc3c8d09364f
interface IWinptyNative {
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..5c28e6465a6fd506cbc6b548b6d9cb918d3be0da 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -446,7 +499,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..5c28e6465a6fd506cbc6b548b6d9cb91
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..dc8dd58bf6138d3b3cc2bede3efb6fdf7dbeb28f 100644
index 7b286d3d644c26141df516929703aa6e129df4b2..16f8d99c627159a35cb643a2752ded3094399230 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -45,6 +45,7 @@ struct pty_baton {
@@ -722,10 +775,19 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..dc8dd58bf6138d3b3cc2bede3efb6fdf
};
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
index d7054449516f0c9a62af351c2caa17331206d530..43e44678826d5883691931875a91cdf7ae141dd0 100644
index d7054449516f0c9a62af351c2caa17331206d530..5cc9914ee1f1b8b40dadfb8164763357f5549300 100644
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -56,7 +56,8 @@ export class WindowsPtyAgent {
@@ -34,6 +34,8 @@ export class WindowsPtyAgent {
private _innerPid: number = 0;
private _closeTimeout: NodeJS.Timer | undefined;
private _exitCode: number | undefined;
+ private _processExited: boolean = false;
+ private _processExitListeners: (() => void)[] = [];
private _conoutSocketWorker: ConoutConnection;
private _fd: any;
@@ -56,7 +58,8 @@ export class WindowsPtyAgent {
debug: boolean,
private _useConpty: boolean | undefined,
private _useConptyDll: boolean = false,
@@ -735,7 +797,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..43e44678826d5883691931875a91cdf7
) {
if (this._useConpty === undefined || this._useConpty === true) {
this._useConpty = this._getWindowsBuildNumber() >= 18309;
@@ -116,7 +117,7 @@ export class WindowsPtyAgent {
@@ -116,7 +119,7 @@ export class WindowsPtyAgent {
this._inSocket.setEncoding('utf8');
if (this._useConpty) {
@@ -744,7 +806,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..43e44678826d5883691931875a91cdf7
this._innerPid = connect.pid;
}
}
@@ -138,6 +139,13 @@ export class WindowsPtyAgent {
@@ -138,6 +141,21 @@ export class WindowsPtyAgent {
}
}
@@ -754,24 +816,72 @@ index d7054449516f0c9a62af351c2caa17331206d530..43e44678826d5883691931875a91cdf7
+ }
+ return (this._ptyNative as IConptyNative).terminateJobTree?.(this._pty, timeoutMs);
+ }
+
+ public onProcessExit(listener: () => void): void {
+ if (this._processExited) {
+ listener();
+ return;
+ }
+ this._processExitListeners.push(listener);
+ }
+
public kill(): void {
// Tell the agent to kill the pty, this releases handles to the process
if (this._useConpty) {
@@ -223,6 +241,10 @@ export class WindowsPtyAgent {
*/
private _$onProcessExit(exitCode: number): void {
this._exitCode = exitCode;
+ this._processExited = true;
+ const listeners = this._processExitListeners;
+ this._processExitListeners = [];
+ listeners.forEach(listener => listener());
if (!this._useConptyDll) {
this._flushDataAndCleanUp();
this._outSocket.on('data', () => this._flushDataAndCleanUp());
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
index 13f6c6dbb3d7965c4699568f4704be3b6a33d947..a1189c0fd9d2bc4d9cb31271c9924b60acc04585 100644
index 13f6c6dbb3d7965c4699568f4704be3b6a33d947..23f2f2d5b7f95df60a22adad9c4d7cbdc1a3dd5f 100644
--- a/src/windowsTerminal.ts
+++ b/src/windowsTerminal.ts
@@ -48,7 +48,7 @@ export class WindowsTerminal extends Terminal {
@@ -48,8 +48,23 @@ 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;
+ let socketClosed = false;
+ let agentProcessExited = false;
+ let exitEmitted = false;
+ const emitExitWhenSafe = (): void => {
+ if (exitEmitted || !socketClosed || (opt.windowsAgentJob && !agentProcessExited)) {
+ return;
+ }
+ exitEmitted = true;
+ this.emit('exit', this._agent.exitCode);
+ this._close();
+ };
+ this._agent.onProcessExit(() => {
+ agentProcessExited = true;
+ emitExitWhenSafe();
+ });
// Not available until `ready` event emitted.
@@ -161,6 +161,10 @@ export class WindowsTerminal extends Terminal {
this._pid = this._agent.innerPid;
@@ -103,8 +118,10 @@ export class WindowsTerminal extends Terminal {
// Cleanup after the socket is closed.
this._socket.on('close', () => {
- this.emit('exit', this._agent.exitCode);
- this._close();
+ // Why: a Job-owned root can close ConPTY output before the native exit
+ // callback proves every descendant is gone; public exit needs both facts.
+ socketClosed = true;
+ emitExitWhenSafe();
});
});
@@ -161,6 +178,10 @@ export class WindowsTerminal extends Terminal {
});
}
@@ -783,7 +893,7 @@ index 13f6c6dbb3d7965c4699568f4704be3b6a33d947..a1189c0fd9d2bc4d9cb31271c9924b60
this._deferNoArgs(() => {
if (signal) {
diff --git a/typings/node-pty.d.ts b/typings/node-pty.d.ts
index 6f050ff1d97b8d3477be7ee95d2af516bd9517b7..4387cafd16596dd0f4674549ff2deecb884f30a7 100644
index 6f050ff1d97b8d3477be7ee95d2af516bd9517b7..b26831f60d2aba6cc352fad561cd0c3f7b9bfe02 100644
--- a/typings/node-pty.d.ts
+++ b/typings/node-pty.d.ts
@@ -108,6 +108,9 @@ declare module 'node-pty' {
+11 -3
View File
@@ -712,19 +712,20 @@
"ssh",
"runtime"
],
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A Windows 2022 Actions red/green run proves production store.removeWorktree(id, true) retires an agent PTY and its reparented native lock-holder before deleting the worktree, while a separate live test proves plain terminal close preserves an intentionally detached child. Deterministic tests cover native Windows agent-only Job ownership, atomic spawn failure, fail-closed Job drain and bridge errors, safe failure when Job ownership is unavailable, daemon-wide shutdown, daemon and SSH routing, 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, SSH, 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. A Windows 2022 Actions red/green run proves production store.removeWorktree(id, true) retires an agent PTY and its reparented native lock-holder before deleting the worktree, while a separate live test proves plain terminal close preserves an intentionally detached child. Deterministic tests cover native Windows agent-only Job selection, fail-closed Job teardown and bridge errors, safe failure when Job ownership is unavailable, adopted-v24 destructive-shutdown refusal, daemon WSL exclusion, daemon-wide shutdown, daemon and SSH routing, 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, SSH, 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, while plain terminals, WSL, and SSH 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. Unit tests require agent-only spawn-time Job ownership, Job termination before root close, ActiveProcesses zero before success, fail-closed timeout/rejection/synchronous-throw behavior, 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.",
"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, Job termination success before root close, fail-closed timeout/rejection/synchronous-throw behavior, adopted-v24 destructive-shutdown refusal, daemon 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 exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.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 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",
"gh workflow run windows-terminal-restart-e2e.yml --ref OrcaWin/issue-9045-worktree-deletion-fails"
],
"testFiles": [
@@ -746,7 +747,8 @@
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts"
"tests/e2e/agent-descendant-process-kill.spec.ts",
"tests/e2e/windows-agent-job-natural-exit.spec.ts"
],
"assertionRefs": [
{
@@ -823,6 +825,12 @@
"production Windows worktree deletion removes a reparented native lock-holder plus its PTY, root, agent, directory, and Git registration",
"plain Windows terminal close preserves an intentionally detached child"
]
},
{
"file": "tests/e2e/windows-agent-job-natural-exit.spec.ts",
"assertions": [
"a natural native Windows agent root exit publishes PTY exit only after its detached Job child is absent"
]
}
],
"evidenceRuns": [
+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: 37444feda4521a62bd76f2abc04efe142a09134f26b1ef0a7f4cf0e0bbbfdedf
hash: 942526bf93620d3912de272d03406fdce9356755039f9fefe6265012f1806e8a
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=37444feda4521a62bd76f2abc04efe142a09134f26b1ef0a7f4cf0e0bbbfdedf)
version: 1.1.0(patch_hash=942526bf93620d3912de272d03406fdce9356755039f9fefe6265012f1806e8a)
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=37444feda4521a62bd76f2abc04efe142a09134f26b1ef0a7f4cf0e0bbbfdedf):
node-pty@1.1.0(patch_hash=942526bf93620d3912de272d03406fdce9356755039f9fefe6265012f1806e8a):
dependencies:
node-addon-api: 7.1.1
@@ -1,5 +1,6 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
export const PROTOCOL_VERSION = 25
export const WINDOWS_AGENT_JOB_PROTOCOL_VERSION = 25
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
@@ -532,6 +532,43 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
describe('shutdown', () => {
it('fails closed for destructive shutdown routed to an adopted Windows v24 daemon', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue(undefined)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 24 })
try {
await expect(
legacy.shutdown('legacy-session', {
immediate: true,
deadlineMs: Date.now() + 5_000
})
).rejects.toThrow('requires daemon protocol 25 or newer')
expect(ensureConnectedSpy).not.toHaveBeenCalled()
expect(requestSpy).not.toHaveBeenCalled()
await expect(
legacy.shutdown('legacy-session', { immediate: true })
).resolves.toBeUndefined()
expect(requestSpy).toHaveBeenCalledWith(
'kill',
{ sessionId: 'legacy-session', immediate: true },
undefined
)
} finally {
legacy.dispose()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
it('kills the session', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
await adapter.shutdown(id, { immediate: false })
+13
View File
@@ -14,7 +14,9 @@ import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
WINDOWS_AGENT_JOB_PROTOCOL_VERSION,
supportsPtyStartupIngress,
DaemonProtocolError,
type CreateOrAttachResult,
type DaemonEvent,
type GetSnapshotResult,
@@ -584,6 +586,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
if (
process.platform === 'win32' &&
opts.deadlineMs !== undefined &&
this.protocolVersion < WINDOWS_AGENT_JOB_PROTOCOL_VERSION
) {
// Why: an adopted v24 daemon could not own agent descendants at spawn;
// destructive deletion must not mistake its root-only exit for a safe drain.
throw new DaemonProtocolError(
`Windows destructive PTY shutdown requires daemon protocol ${WINDOWS_AGENT_JOB_PROTOCOL_VERSION} or newer`
)
}
// Why: shutdown can be the first lazy-client operation after restart; connect
// before killing so a healthy daemon session is not orphaned (#7742). Connect
// and kill share the caller's one absolute deadline, so a wedged handshake
+31
View File
@@ -245,6 +245,37 @@ describe('createPtySubprocess', () => {
}
})
it('keeps daemon agent sessions launched through WSL outside native Windows Job control', () => {
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-wsl-agent',
cols: 80,
rows: 24,
launchAgent: 'claude',
shellOverride: 'wsl.exe',
env: {}
})
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
expect.any(Array),
expect.not.objectContaining({ windowsAgentJob: true })
)
expect(handle.nativeWindowsPty).toBeUndefined()
expect(handle.terminateJobTree).toBeUndefined()
expect(proc.terminateJobTree).not.toHaveBeenCalled()
} 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)
+1
View File
@@ -21,6 +21,7 @@ export {
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
PTY_STARTUP_INGRESS_PROTOCOL_VERSION,
WINDOWS_AGENT_JOB_PROTOCOL_VERSION,
supportsPtyStartupIngress
} from './daemon-protocol-version'
@@ -0,0 +1,228 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
type ExitObservation = {
code: number
childAlive: boolean
observedAt: number
}
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function quotePowerShellArg(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function forceKillWindowsPid(pid: number): Promise<boolean> {
if (!isProcessAlive(pid)) {
return true
}
const taskkill = process.env.SystemRoot
? join(process.env.SystemRoot, 'System32', 'taskkill.exe')
: 'taskkill'
for (let attempt = 0; attempt < 5 && isProcessAlive(pid); attempt++) {
spawnSync(taskkill, ['/PID', String(pid), '/F'], { windowsHide: true, stdio: 'ignore' })
await delay(250)
}
return !isProcessAlive(pid)
}
test('natural Windows agent root exit waits for Job descendants before public PTY exit', async ({
orcaPage
}, testInfo) => {
test.skip(process.platform !== 'win32', 'requires native Windows ConPTY and Job Objects')
const stage = mkdtempSync(join(tmpdir(), 'orca-windows-agent-natural-exit-'))
const agentScript = join(stage, 'agent.cjs')
const childScript = join(stage, 'child.cjs')
const releasePath = join(stage, 'release')
const rootPidPath = join(stage, 'root.pid')
const agentPidPath = join(stage, 'agent.pid')
const childPidPath = join(stage, 'child.pid')
const powershellPath = join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
writeFileSync(
childScript,
[
"const { writeFileSync } = require('node:fs')",
'writeFileSync(process.argv[2], String(process.pid))',
'setInterval(() => {}, 1000)',
''
].join('\n')
)
writeFileSync(
agentScript,
[
"const { existsSync, writeFileSync } = require('node:fs')",
"const { spawn } = require('node:child_process')",
'writeFileSync(process.argv[2], String(process.pid))',
'const child = spawn(process.execPath, [process.argv[3], process.argv[4]], {',
" cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true",
'})',
'child.unref()',
'const poll = setInterval(() => {',
' if (existsSync(process.argv[5])) { clearInterval(poll); process.exit(0) }',
'}, 25)',
''
].join('\n')
)
let ptyId = ''
let rootPid = 0
let agentPid = 0
let childPid = 0
let exitObservation: ExitObservation | null = null
const cleanupFailures: string[] = []
const evidence: Record<string, unknown> = { stage }
await orcaPage.exposeFunction('__orcaRecordWindowsAgentJobExit', ({ code }: { code: number }) => {
exitObservation = { code, childAlive: isProcessAlive(childPid), observedAt: Date.now() }
})
try {
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
const command = [
`[IO.File]::WriteAllText(${quotePowerShellArg(rootPidPath)}, [string]$PID);`,
'&',
quotePowerShellArg(process.execPath),
quotePowerShellArg(agentScript),
quotePowerShellArg(agentPidPath),
quotePowerShellArg(childScript),
quotePowerShellArg(childPidPath),
quotePowerShellArg(releasePath),
'; exit $LASTEXITCODE'
].join(' ')
ptyId = await orcaPage.evaluate(
async ({ command: startupCommand, cwd, shellOverride, worktreeId: wt }) => {
const result = await window.api.pty.spawn({
cols: 120,
rows: 40,
cwd,
command: startupCommand,
launchAgent: 'claude',
shellOverride,
worktreeId: wt
})
return result.id
},
{ command, cwd: stage, shellOverride: powershellPath, worktreeId }
)
await orcaPage.evaluate((id) => {
const target = window as typeof window & {
__orcaRecordWindowsAgentJobExit: (payload: { code: number }) => Promise<void>
__orcaDisposeWindowsAgentJobExit?: () => void
}
target.__orcaDisposeWindowsAgentJobExit = window.api.pty.onExit((payload) => {
if (payload.id === id) {
void target.__orcaRecordWindowsAgentJobExit({ code: payload.code })
}
})
}, ptyId)
await expect
.poll(() => existsSync(childPidPath), {
timeout: 20_000,
message: 'agent never spawned its detached native child'
})
.toBe(true)
agentPid = Number(readFileSync(agentPidPath, 'utf8').trim())
childPid = Number(readFileSync(childPidPath, 'utf8').trim())
const management = await orcaPage.evaluate(async (id) => {
const result = await window.api.pty.management.listSessions()
const session = result.sessions.find((candidate) => candidate.sessionId === id)
return session ? { pid: session.pid, protocolVersion: session.protocolVersion } : null
}, ptyId)
// Why: v24 sessions predate spawn-time Job ownership, so only a new v25
// session can prove the natural-exit drain contract under test.
expect(management?.protocolVersion).toBe(25)
rootPid = management?.pid ?? 0
expect(rootPid).toBe(Number(readFileSync(rootPidPath, 'utf8').trim()))
expect(rootPid).toBeGreaterThan(0)
expect(agentPid).toBeGreaterThan(0)
expect(childPid).toBeGreaterThan(0)
expect(isProcessAlive(rootPid)).toBe(true)
expect(isProcessAlive(agentPid)).toBe(true)
expect(isProcessAlive(childPid)).toBe(true)
await delay(500)
expect(isProcessAlive(childPid), 'detached child must be long-lived before release').toBe(true)
evidence.before = { ptyId, rootPid, agentPid, childPid }
writeFileSync(releasePath, 'release')
await expect
.poll(
() => ({
publicExit: exitObservation !== null,
rootAlive: isProcessAlive(rootPid),
agentAlive: isProcessAlive(agentPid),
childAlive: isProcessAlive(childPid)
}),
{ timeout: 20_000, intervals: [10, 10, 25, 50, 100] }
)
.toEqual({ publicExit: true, rootAlive: false, agentAlive: false, childAlive: false })
expect(exitObservation?.childAlive, 'public exit arrived before the Job drained').toBe(false)
expect(exitObservation?.code).toBe(0)
await expect.poll(() => orcaPage.evaluate((id) => window.api.pty.hasPty(id), ptyId)).toBe(false)
evidence.after = { exitObservation, rootAlive: false, agentAlive: false, childAlive: false }
} finally {
await orcaPage
.evaluate(() => {
const target = window as typeof window & {
__orcaDisposeWindowsAgentJobExit?: () => void
}
target.__orcaDisposeWindowsAgentJobExit?.()
delete target.__orcaDisposeWindowsAgentJobExit
})
.catch(() => undefined)
if (ptyId) {
await orcaPage.evaluate((id) => window.api.pty.kill(id), ptyId).catch(() => undefined)
}
for (const [name, pid] of [
['root', rootPid],
['agent', agentPid],
['child', childPid]
] as const) {
if (!(await forceKillWindowsPid(pid))) {
cleanupFailures.push(`${name} process ${pid} remained alive`)
}
}
evidence.cleanupFailures = cleanupFailures
const evidencePath = testInfo.outputPath('windows-agent-job-natural-exit-evidence.json')
writeFileSync(evidencePath, JSON.stringify(evidence, null, 2))
await testInfo.attach('windows-agent-job-natural-exit-evidence.json', {
path: evidencePath,
contentType: 'application/json'
})
if (cleanupFailures.length === 0) {
rmSync(stage, { recursive: true, force: true })
}
}
expect(cleanupFailures, `Windows E2E cleanup failed: ${cleanupFailures.join('; ')}`).toEqual([])
})