fix: close readiness gaps found by merged-change audit (#17159)

* fix(ssh): fence stale kills and retired pane replay

* fix(ssh): support cancellable interactive authentication

* fix(ssh): await remote catalog before snapshot adoption

* fix(pty): contain Windows ConPTY input failures

* fix(power): avoid redundant macOS display blocking

* perf(editor): narrow markdown override subscriptions

* fix(quick-open): close directory handles after reads

* refactor(linux): remove unused proc socket scanner

* fix(usage): apply flat Sonnet 4.6 pricing

* ci: prime Node next native test cache

* docs(skills): resolve snapshot cleanup data path

* fix(ssh): recover install locks after host reboot

* test(ssh): recognize boot-aware install locks

* test(ssh): prove previous-boot lock recovery live

* test(wire): pin pre-metadata release coverage

* fix(terminal): preserve remote tab ownership through recovery races

* test(runtime): fence replaced terminal handles in agent guard

* fix(ssh): preserve remote snapshot authority across polls

* fix(pty): contain late ConPTY output EPIPE

* test(pty): register Windows exit watcher before kill

* fix: close SSH and tab readiness race gaps

* fix(tabs): retain headless order and placeholder titles

* fix(build): avoid parallel electron-vite config race

* test(windows): avoid MSYS temp path rewriting

* test(windows): avoid killing exited PTY

* fix(pty): avoid late ConPTY input teardown race

* fix(terminal): sync reconnect error ownership after commit

* fix(runtime): use canonical worktree identity comparison

* test(ssh): assert complete cold-hydration baseline

* test(windows): invoke quoted retention fixture via PowerShell

* test(windows): read ConPTY grid through mode con

* fix(terminal): publish PTY replacements atomically

* fix(terminal): infer stale identity on reattach

* fix(terminal): fence stale pane PTY callbacks

* fix(terminal): fence stale pane binds after rebind

* fix(terminal): reject stale pane transport callbacks

* fix(terminal): fence mirrored reattach spawn callbacks

* fix(terminal): replace stale pane PTYs on remount

* fix(ci): size the Windows launcher-compile test budget from measurement

`native-smoke (windows-latest)` fails ~4.5% of runs on
`preserves a multiline argument through the compiled remote launcher`
with "Test timed out in 15000ms" — on unrelated PRs, for reasons that
have nothing to do with them. Across 176 sampled attempts it is the only
red that job produced, and it hit seven different PRs in two days:
#16900, #16904, #16915, #16955 (twice), #16979, #17014, #17085.

The test is six process creations: powershell.exe forks csc.exe, then
the freshly compiled orca.exe forks node.exe, twice. Hosted Windows
runners periodically slow process creation down, and this test amplifies
that far harder than anything else in the job. Comparing the 80 attempts
where it ran under 3s against the 12 where it ran over 12s, its own
median goes 2198ms -> 15917ms (7.2x) while the same file's
powershell-only test moves 556 -> 686ms (1.2x), the cmd.exe and Git Bash
process tests in the neighbouring file move 1.4x, and the other 35 files
put together move 1.5x.

Measured across those 176 attempts: 1881ms to 35438ms, p50 4264ms,
correlation +0.881 with the job's total Vitest duration. 8 of 176 (4.5%)
exceeded the 15s cap; 2 of 176 (1.1%) also exceeded the shared 30s
testTimeout, so deleting the override and inheriting the config is not
enough on its own. 60s clears all 176 with 1.7x headroom on the worst.

This is slow, not hung. Every body here is synchronous spawnSync, so
Vitest cannot interrupt one — the timer fires only after the body
returns and the reported duration is real elapsed time. That is why a
failure reads `× ... 22464ms` under `Test timed out in 15000ms`. The
work finished; the stopwatch was short. Seven reruns at one identical
head measured 2053 / 4680 / 5551 / 8732 / 13506 / 14868 / 21937ms — the
last of those would have been red on code that had not changed.

The 15s came from #8897, which raised this test off Vitest's built-in 5s
default because the job then ran bare `pnpm vitest run`. #8909 landed
3h27m later and pointed the job at config/vitest.config.ts, which is the
real fix for that. The constant stayed behind and has been the binding
budget ever since.

* fix(terminal): fence stale remount reattach ownership

* fix(terminal): reconcile mounted pane identity after replacement

* fix(terminal): fence stale reattach fallback ownership

* fix(terminal): fence deferred SSH reattach ownership

* fix(terminal): fence stale split pane ownership callbacks

* fix(terminal): keep stale spawns from consuming startup

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Neil
2026-08-31 08:17:40 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 212c0e42a6
commit fbe94ceff6
284 changed files with 19156 additions and 2604 deletions
+17
View File
@@ -14,7 +14,24 @@ permissions:
contents: read
jobs:
# A cold cache would otherwise make all eight Node 26 shards compile the same native addons.
test_native_cache:
name: prepare test native cache node 26
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: '26'
test:
needs: [test_native_cache]
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["26"]'
+197
View File
@@ -789,3 +789,200 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
return exports;
};
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
index a358ffb..fb3a96f 100644
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () {
if (this._useConpty) {
if (!this._useConptyDll) {
this._inSocket.readable = false;
+ // The non-DLL path previously only flipped `readable`, leaving the
+ // conin PipeWrap alive until the host exited (#947).
+ this._inSocket.destroy();
this._outSocket.readable = false;
this._getConsoleProcessList().then(function (consoleProcessList) {
consoleProcessList.forEach(function (pid) {
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
index 3c38f89..e20b3e6 100644
--- a/lib/windowsTerminal.js
+++ b/lib/windowsTerminal.js
@@ -50,6 +50,27 @@ var WindowsTerminal = /** @class */ (function (_super) {
// Create new termal.
_this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
_this._socket = _this._agent.outSocket;
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
+ _this._socket.on('error', function (err) {
+ var code = err && err.code;
+ // PTY output can report EPIPE before `_close()` wins the race.
+ _this._close();
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
+ return;
+ }
+ // EIO, happens when someone closes our child process: the only process
+ // in the terminal.
+ // node < 0.6.14: errno 5
+ // node >= 0.6.14: read EIO
+ if (typeof code === 'string') {
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))
+ return;
+ }
+ // Throw anything else.
+ if (_this.listeners('error').length < 2) {
+ throw err;
+ }
+ });
// Not available until `ready` event emitted.
_this._pid = _this._agent.innerPid;
_this._fd = _this._agent.fd;
@@ -76,23 +99,6 @@ var WindowsTerminal = /** @class */ (function (_super) {
_this._deferreds = [];
}
});
- // Shutdown if `error` event is emitted.
- _this._socket.on('error', function (err) {
- // Close terminal session.
- _this._close();
- // EIO, happens when someone closes our child process: the only process
- // in the terminal.
- // node < 0.6.14: errno 5
- // node >= 0.6.14: read EIO
- if (err.code) {
- if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))
- return;
- }
- // Throw anything else.
- if (_this.listeners('error').length < 2) {
- throw err;
- }
- });
// Cleanup after the socket is closed.
_this._socket.on('close', function () {
_this.emit('exit', _this._agent.exitCode);
@@ -103,6 +109,20 @@ var WindowsTerminal = /** @class */ (function (_super) {
_this._name = name;
_this._readable = true;
_this._writable = true;
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
+ _this._agent.inSocket.on('error', function () {
+ if (!_this._writable) {
+ return;
+ }
+ _this._close();
+ try {
+ _this._agent.kill();
+ }
+ catch (_a) {
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
+ }
+ });
_this._forwardEvents();
return _this;
}
@@ -196,4 +216,4 @@ var WindowsTerminal = /** @class */ (function (_super) {
return WindowsTerminal;
}(terminal_1.Terminal));
exports.WindowsTerminal = WindowsTerminal;
-//# sourceMappingURL=windowsTerminal.js.map
\ No newline at end of file
+//# sourceMappingURL=windowsTerminal.js.map
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
index d705444..ce611b8 100644
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -143,6 +143,9 @@ export class WindowsPtyAgent {
if (this._useConpty) {
if (!this._useConptyDll) {
this._inSocket.readable = false;
+ // The non-DLL path previously only flipped `readable`, leaving the
+ // conin PipeWrap alive until the host exited (#947).
+ this._inSocket.destroy();
this._outSocket.readable = false;
this._getConsoleProcessList().then(consoleProcessList => {
consoleProcessList.forEach((pid: number) => {
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
index 13f6c6d..eda63c8 100644
--- a/src/windowsTerminal.ts
+++ b/src/windowsTerminal.ts
@@ -51,6 +51,30 @@ export class WindowsTerminal extends Terminal {
this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);
this._socket = this._agent.outSocket;
-
+
+ // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.
+ this._socket.on('error', err => {
+ const code = (<any>err).code;
+
+ // PTY output can report EPIPE before `_close()` wins the race.
+ this._close();
+ if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {
+ return;
+ }
+
+ // EIO, happens when someone closes our child process: the only process
+ // in the terminal.
+ // node < 0.6.14: errno 5
+ // node >= 0.6.14: read EIO
+ if (typeof code === 'string') {
+ if (~code.indexOf('errno 5') || ~code.indexOf('EIO')) return;
+ }
+
+ // Throw anything else.
+ if (this.listeners('error').length < 2) {
+ throw err;
+ }
+ });
+
// Not available until `ready` event emitted.
this._pid = this._agent.innerPid;
this._fd = this._agent.fd;
@@ -82,25 +108,6 @@ export class WindowsTerminal extends Terminal {
}
});
-
+
- // Shutdown if `error` event is emitted.
- this._socket.on('error', err => {
- // Close terminal session.
- this._close();
-
- // EIO, happens when someone closes our child process: the only process
- // in the terminal.
- // node < 0.6.14: errno 5
- // node >= 0.6.14: read EIO
- if ((<any>err).code) {
- if (~(<any>err).code.indexOf('errno 5') || ~(<any>err).code.indexOf('EIO')) return;
- }
-
- // Throw anything else.
- if (this.listeners('error').length < 2) {
- throw err;
- }
- });
-
// Cleanup after the socket is closed.
this._socket.on('close', () => {
this.emit('exit', this._agent.exitCode);
@@ -114,6 +121,19 @@ export class WindowsTerminal extends Terminal {
-
+
this._readable = true;
this._writable = true;
+ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes
+ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.
+ this._agent.inSocket.on('error', () => {
+ if (!this._writable) {
+ return;
+ }
+ this._close();
+ try {
+ this._agent.kill();
+ } catch {
+ // The failing pipe may have raced process exit; the terminal is already unwritable.
+ }
+ });
-
+
this._forwardEvents();
}
+295 -43
View File
@@ -998,7 +998,7 @@
},
{
"id": "ssh-relay.staged-upload-recovery",
"title": "SSH relay uploads remain retryable before the shared install lock",
"title": "SSH relay uploads and install locks remain retryable",
"maturity": "experimental",
"protection": "partial",
"owner": "ssh-relay-install",
@@ -1007,19 +1007,21 @@
"SSH relay first install",
"split shell and SFTP namespaces",
"system SSH transfer fallback",
"relay install retry after cancellation"
"relay install retry after cancellation",
"post-promotion retry after execution-host restart"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["ssh2", "system-ssh"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["ssh2", "system-ssh"],
"coverageNotes": "Deterministic unit, exact POSIX shell, native ARM macOS PowerShell 7.6.4, and real ssh2 SFTP-wire tests cover lock ordering, concurrent-install loss, fixed-slot ownership identity, payload-only promotion, bounded stale-stage reclamation, installed-fast-path draining, joined cancellation teardown, cross-version isolation, split-SFTP redirection, and system-SSH bypass. A throwaway linux-arm64 Docker sshd reached through a non-loopback LAN address covers live bytes-in-flight SFTP cancellation, injected unconfirmed cancellation, immediate retry against a real Git repository, fixed-slot recovery behind unclaimable entries, and real version-GC filtering with 15,197 unrelated names.",
"coverageNotes": "Deterministic unit, exact POSIX shell, native ARM macOS PowerShell 7.6.4, and real ssh2 SFTP-wire tests cover lock ordering, concurrent-install loss, fixed-slot ownership identity, payload-only promotion, bounded stale-stage reclamation, boot-identity takeover, installed-fast-path draining, joined cancellation teardown, cross-version isolation, split-SFTP redirection, and system-SSH bypass. A throwaway linux-arm64 Docker sshd reached through a non-loopback LAN address covers live bytes-in-flight SFTP cancellation, injected unconfirmed cancellation, immediate retry against a real Git repository, fixed-slot recovery behind unclaimable entries, and real version-GC filtering with 15,197 unrelated names.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/9828",
"https://github.com/stablyai/orca/pull/10207"
"https://github.com/stablyai/orca/pull/10207",
"https://github.com/stablyai/orca/issues/17144"
],
"invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.",
"oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.",
"invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. A newly acquired install lock atomically records the execution host's boot identity; only a verified identity change or the existing stale-age proof may replace it, while legacy, missing, malformed, and unreadable identity state must retain the conservative stale fallback. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.",
"oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Keep a fresh install lock on the current POSIX or Windows boot and require takeover to fail; replace its bounded identity with a prior-boot value and race concurrent recoverers, requiring exactly one atomic winner, a current successor identity, and no tombstone residue; omit the marker and require the legacy lock to remain fenced. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.",
"commands": [
"node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell <PowerShell-7.6.4-executable> src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
"ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST=<non-loopback-host> ORCA_REVIEW_SSH_IMAGE=<throwaway-sshd-image> ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose"
@@ -1074,7 +1076,8 @@
"assertions": [
"uses encoded PowerShell for Windows deploy commands",
"enumerates Windows staging children before copying",
"lets only one PowerShell caller acquire a legacy-visible lock"
"lets only one PowerShell caller acquire a legacy-visible lock",
"keeps current and legacy fresh locks fenced while one concurrent caller replaces a previous-boot lock"
]
},
{
@@ -1091,6 +1094,7 @@
{
"file": "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts",
"assertions": [
"replaces a fresh previous-boot install lock over live ssh2 while preserving promoted payload and leaving no tombstone",
"aborts a live SFTP upload after remote bytes arrive without creating the shared lock",
"recovers cancellation with bounded safe reclamation and bounded real version GC"
]
@@ -1130,7 +1134,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Stage recovery examines only eight fixed slot/claim/delete paths and reclaims at most one stale valid stage per invocation; installed reconnects launch before asynchronous recovery. Full quota produces an explicit error instead of unbounded cleanup. Version GC still scans the relay base directory, but remote filtering caps stdout and local candidate work at 64. Cancellation adds one bounded five-second join of channel and transfer settlement."
"evidence": "Stage recovery examines only eight fixed slot/claim/delete paths and reclaims at most one stale valid stage per invocation; installed reconnects launch before asynchronous recovery. Install-lock identity is recorded once per acquisition and checked only during the existing at-most-once-per-minute recovery probe; marker reads are capped at 128 bytes. Full quota produces an explicit error instead of unbounded cleanup. Version GC still scans the relay base directory, but remote filtering caps stdout and local candidate work at 64. Cancellation adds one bounded five-second join of channel and transfer settlement."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
@@ -1140,6 +1144,7 @@
"knownGaps": [
"The live Docker target is Linux ARM64 with a unified namespace; split-SFTP behavior is covered by real ssh2 wire and deterministic deploy fixtures.",
"Native PowerShell coverage runs on ARM macOS with POSIX filesystem paths; Windows OpenSSH, Windows PowerShell 5.1, and system-SSH behavior remain command and transfer-contract coverage rather than a live target.",
"No live VM or WSL reboot is injected during native-dependency installation; deterministic host-native command tests provide the previous-boot, current-boot, legacy-marker, and concurrent-takeover oracle.",
"The fixed pool retains up to eight relay bundles; eight foreign or otherwise unclaimable fixed states require manual inspection instead of automatic deletion.",
"Version GC remotely filters and caps output but still scans the base .orca-remote directory; it does not promise constant remote enumeration time.",
"The Docker oracle is opt-in because it requires a local image and a reachable non-loopback host address."
@@ -8405,7 +8410,7 @@
"providers": ["local", "daemon", "wsl"],
"coveredPlatforms": ["windows"],
"coveredProviders": ["daemon"],
"coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.",
"coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. A Windows-only patched-node-pty test injects EAGAIN on one ConPTY input pipe and requires only that PTY to close while an unrelated PTY remains writable; a daemon-level classifier test keeps the native exception backstop narrow. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6541",
"https://github.com/stablyai/orca/pull/6858",
@@ -8413,18 +8418,21 @@
"https://github.com/stablyai/orca/pull/6968",
"https://github.com/stablyai/orca/pull/6970",
"https://github.com/stablyai/orca/pull/6999",
"https://github.com/stablyai/orca/issues/8048"
"https://github.com/stablyai/orca/issues/8048",
"https://github.com/stablyai/orca/issues/17027"
],
"invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.",
"oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.",
"invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, contain an asynchronous ConPTY input-pipe failure to the affected terminal without killing the daemon, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.",
"oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. The EAGAIN slice emits an error from one real patched node-pty Windows input socket, requires its terminal to become unwritable and run the normal per-PTY kill path, then writes through an unrelated PTY without an uncaught exception. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.",
"commands": [
"pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/node-pty-windows-input-error.win32.test.ts src/main/daemon/daemon-native-pty-exception.test.ts",
"pnpm build:electron-vite && node config/scripts/windows-daemon-workspace-close-repro.mjs",
"node config/scripts/windows-daemon-workspace-close-repro.mjs"
],
"testFiles": [
"src/main/daemon/pty-subprocess.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/daemon/node-pty-windows-input-error.win32.test.ts",
"src/main/daemon/daemon-native-pty-exception.test.ts",
"config/scripts/windows-daemon-workspace-close-repro.mjs"
],
"assertionRefs": [
@@ -8441,6 +8449,18 @@
"a failed atomic history seed remains non-authoritative across adapter restart and cannot overwrite the recovery files"
]
},
{
"file": "src/main/daemon/node-pty-windows-input-error.win32.test.ts",
"assertions": [
"an EAGAIN event retires only the affected patched node-pty terminal while a witness remains writable"
]
},
{
"file": "src/main/daemon/daemon-native-pty-exception.test.ts",
"assertions": [
"the daemon suppresses native PTY errno failures while rejecting non-Error and unrelated logic failures"
]
},
{
"file": "config/scripts/windows-daemon-workspace-close-repro.mjs",
"assertions": [
@@ -8482,6 +8502,7 @@
],
"knownGaps": [
"Real IME composition may require a separate lower-layer/native-text-forwarding gate.",
"The EAGAIN oracle injects the real input socket event rather than inducing kernel resource exhaustion on a packaged Windows host.",
"The built-daemon harness proves process/session liveness but not renderer pixels; visible shell input, resize, cursor, and CJK repaint remain uncovered."
],
"demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only."
@@ -10326,6 +10347,8 @@
"remote-runtime host surface materialization",
"remote-runtime mirror polling",
"remote-runtime network recovery",
"pane-scoped remote terminal recovery errors",
"bounded terminal error surfaces",
"paired client sleep/wake reconnect",
"terminal create idempotency",
"provider listing",
@@ -10337,7 +10360,7 @@
"providers": ["ssh", "remote-runtime", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["ssh", "remote-runtime"],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, an incoming snapshot revokes replace-session upload authority before preparation yields, already-captured uploads revalidate that exact authority after local persistence settles, and main rejects their applied revision when a newer host snapshot arrives before IPC admission or while queued. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-3107",
"https://github.com/stablyai/orca/pull/12664",
@@ -10346,17 +10369,25 @@
"https://github.com/stablyai/orca/pull/6979",
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597",
"https://github.com/stablyai/orca/issues/11541"
"https://github.com/stablyai/orca/issues/11541",
"https://github.com/stablyai/orca/issues/15141",
"https://github.com/stablyai/orca/issues/12685",
"https://github.com/stablyai/orca/issues/12902"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Snapshot arrival work may leave at most one readiness or placement timer and one placement subscription live per target; a newer arrival or sync stop must release the previous wait immediately, no superseded snapshot may hydrate after its cancellation, a superseded revision-zero upload may not publish sync status, an incoming snapshot target must be upload-ineligible before snapshot preparation yields, and every upload captured earlier must revalidate the same target authority after its local write and before result publication. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination. A 32-arrival unplaced-snapshot burst requires exactly one live placement listener and timer throughout, then applies only revision 51 after the catalog arrives and releases both resources; a second 32-arrival burst requires exactly one readiness timer and releases it on stop, while a deferred revision-zero upload cannot publish after a newer snapshot arrives. The real persistence subscriber must also exclude a previously hydrated target from replace-session uploads while incoming snapshot capture is pending, including when the upload captured that target before its local disk write stalled. Stopping with an active placement wait releases it immediately and hydrates nothing. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"pnpm run build:web-from-renderer",
@@ -10378,6 +10409,7 @@
"src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
"src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts",
@@ -10408,6 +10440,11 @@
"src/renderer/src/hooks/direct-ssh-host-hydration.test.ts",
"src/renderer/src/hooks/direct-ssh-state-routing.test.ts",
"src/renderer/src/hooks/remote-workspace-target-sync.test.ts",
"src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts",
"src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx",
"src/main/ipc/remote-workspace-cache.test.ts",
"src/main/ipc/remote-workspace.test.ts",
"src/main/ipc/remote-workspace-patch-queue.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ipc/ssh.test.ts",
@@ -10468,7 +10505,17 @@
"assertions": [
"cold restored-terminal subscription failure retries and resumes snapshot, output, and input without a fatal error",
"a canonical close before subscription readiness opens exactly one replacement stream without surfacing a fatal error",
"cached terminal pixels remain disconnected until authoritative replay completes"
"cached terminal pixels remain disconnected until authoritative replay completes",
"authoritative current-stream replay clears every distinct error surfaced by that transport"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts",
"assertions": [
"only the active pane's errors render and matching-pane recovery preserves sibling and unrelated messages",
"individual messages and the joined surface remain within 24 lines and 4,000 characters",
"a distinct error storm retains only the newest eight messages for one pane while whole-message dedup remains intact",
"repeated split-pane close churn releases every closed pane id while preserving the live sibling"
]
},
{
@@ -10719,7 +10766,47 @@
"assertions": [
"snapshot hydration preserves newer local recovery and keeps imported PTY ids retryable until exact-attempt transport acknowledgement",
"stale operation tokens cannot apply an older snapshot over current authority",
"target snapshot projection and persisted-terminal reconnect are host-qualified and preserve sibling SSH, local, WSL, and runtime state"
"target snapshot projection and persisted-terminal reconnect are host-qualified and preserve sibling SSH, local, WSL, and runtime state",
"placement and readiness bursts retain one latest-only listener or timer per target, apply only the newest arrival, and release immediately on stop",
"a superseded revision-zero push cannot publish stale status"
]
},
{
"file": "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts",
"assertions": [
"completed target generations are released across repeated target churn",
"a superseded operation that ignores abort cannot become current through generation reuse after a newer operation completes"
]
},
{
"file": "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx",
"assertions": [
"an unsolicited snapshot synchronously revokes its target's upload authority before preparation awaits",
"a session write while snapshot capture is pending cannot replace the host's newly cached tabs",
"an upload captured before snapshot arrival is revalidated after its pending local write and cannot overwrite the incoming revision",
"same-lineage local writes remain uploadable when an earlier overlapping result advances the renderer's acknowledged revision",
"a transient unavailable result retains its observed host token and revision so the next local edit retries"
]
},
{
"file": "src/main/ipc/remote-workspace-cache.test.ts",
"assertions": [
"contiguous same-client patch revisions retain queued renderer bases until a host observation replaces the lineage",
"snapshot eviction removes its upload-revision authority with the same bounded cache entry"
]
},
{
"file": "src/main/ipc/remote-workspace.test.ts",
"assertions": [
"replace-session upload admission requires an explicit applied revision for every hydrated target"
]
},
{
"file": "src/main/ipc/remote-workspace-patch-queue.test.ts",
"assertions": [
"token A is rejected without a patch when a different same-revision host observation arrives before admission or while the upload waits in the same-target queue",
"an evicted token A fails closed after a same-revision refetch stamps a new observation token",
"same-client notification-before-response ordering preserves token A and overlapping queued writes at relay bases 7 then 8"
]
},
{
@@ -10770,6 +10857,51 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.1,
"summary": "Two coordinator tests passed, proving completed target entries are released under repeated churn and generations remain monotonic until every superseded operation settles, preventing ABA re-admission."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 4.04,
"summary": "Three main-process remote-workspace files and 17 tests passed, including token-A rejection after same-revision observations before admission and while queued, eviction/refetch fail-closed behavior, same-client overlap at bases 7 then 8, revision-zero compatibility, reset-relay fallback, and bounded cache lineage."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot",
"result": "passed",
"durationSeconds": 10.44,
"summary": "Three renderer remote-workspace files and 28 tests passed, including acknowledged observation-token propagation, incoming-lineage upload cancellation, same-lineage overlapping upload continuation, transient-unavailable retry authority, and latest-only snapshot arrival fencing."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 4.3,
"summary": "Two focused files and 26 tests passed, including pane-scoped rendering and recovery, sibling-error retention, split-close pane-id churn cleanup, current-transport multi-error clearing, dismissal re-admission, multi-line deduplication, and 8-message/24-line/4,000-character bounds."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 10.7,
"summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts."
},
{
"date": "2026-08-23",
"runner": "local",
@@ -10810,7 +10942,7 @@
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 15.8,
"summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning."
@@ -10866,7 +10998,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed."
"evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. Delayed snapshot placement retains at most one 10-second store subscription and timer per target; supersession and stop abort both immediately, remove the listener, and clear the timer. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Each transport suppresses at most eight distinct error strings and emits at most eight clear callbacks on recovery; each live pane retains at most eight messages, and every message and joined display is clipped to 24 lines and 4,000 characters. Explicit split close and pane replacement release their entries. This adds no provider request, polling, subprocess, or terminal-output work. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only bounded state checks. No live large-terminal-map direct SSH timing is claimed."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@@ -16886,29 +17018,66 @@
"protection": "partial",
"owner": "terminal-runtime-graph",
"layer": "renderer-runtime-graph-and-terminal-stream",
"surfaces": ["host terminal cold park", "paired remote viewer", "multi-pane runtime graph"],
"surfaces": [
"host terminal cold park",
"parked CLI terminal split",
"paired remote viewer",
"multi-pane runtime graph",
"headless runtime restart",
"pending terminal handle recovery"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local-daemon", "ssh-daemon", "paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local-daemon", "paired-runtime"],
"coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, and disposal behavior. One headed paired journey proves input and echoed output while the host pane remains cold-parked.",
"coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, disposal behavior, and queued split routing to an exact cold-parked tab. A real Electron journey runs the shipped dev CLI against the app's isolated profile, proves the exact parked target alone remounts under a bounded lease, completes before the historical 10-second timeout without stealing active tab or focus, re-parks, then reveals two stable-identity panes with independent keyboard/output round trips. Focused mirror-recovery tests preserve a verified binding across pending-handle snapshots, quarantine positive PTY-identity mismatches, accept authoritative ready replacement or removal, and fence recovery to the pairing revision. Daemon attach-only tests prove a replacement runtime re-registers one active session and one history writer. Headed cold-park and headless runtime-restart paired journeys prove rendered output, input, resize convergence, process identity, authoritative close, checkpoint continuity, and post-restart history append against real daemon PTYs.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-2854",
"https://github.com/stablyai/orca/pull/15514"
"https://github.com/stablyai/orca/pull/15514",
"https://github.com/stablyai/orca/issues/12115",
"https://github.com/stablyai/orca/issues/17297"
],
"invariant": "Cold parking a host renderer pane never retires its live PTY's runtime-graph leaf or interrupts a paired subscriber's stream, input, reconnect, pane identity, or multi-pane routing; the leaf retires only when exact PTY ownership ends.",
"oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf.",
"invariant": "Cold parking or restarting a host renderer/runtime never retires a live PTY's runtime-graph leaf, erases a paired viewer's last verified leaf binding merely because the host temporarily publishes pending-handle, or drops the surviving PTY's history writer. A split request for a cold-parked tab replays against its stable source leaf after exact-tab remount without switching workspaces or focusing the tab. The paired stream, input, resize, pane identity, multi-pane routing, checkpoint, and output log converge to the authoritative ready handle; the binding clears only after positive replacement, mismatch, or two consecutive authoritative absence observations without an intervening ready surface.",
"oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Restart a real pinned-port headless runtime while its daemon PTY survives, observe a pending-handle snapshot, require the viewer binding never to become empty, then require rendered output, focused keyboard input, a post-recovery window resize, the pre-restart checkpoint, and post-restart output-log append to reach the same PTY and process before authoritative close clears the binding. Invoke the real `orca-dev terminal split` against a cold-parked renderer-owned tab, require only that tab to acquire and release the existing background-mount lease, complete before the historical timeout, preserve the active worktree/tab/focus, then reveal two rendered panes and type through each while retaining the stable source leaf and PTY. Deterministically require synchronous stable-leaf replay after lifecycle registration and fail closed for a missing or stale leaf. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf. Require two consecutive successful authoritative inventory absences before removing a missing surface, and reset that confirmation after every healthy ready frame.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
"pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts src/main/daemon/daemon-pty-adapter-history-recovery.test.ts --reporter=dot",
"pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/terminal-parked-cli-split.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts",
"src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts",
"src/main/runtime/orca-runtime-terminal-split-authority.test.ts",
"src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts",
"src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts",
"src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts",
"src/main/daemon/daemon-pty-adapter-session-adoption.test.ts",
"src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts",
"src/main/daemon/daemon-pty-adapter-history-recovery.test.ts",
"tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
"tests/e2e/host-parked-pane-remote-viewer.spec.ts"
"tests/e2e/host-parked-pane-remote-viewer.spec.ts",
"tests/e2e/terminal-parked-cli-split.spec.ts",
"tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts",
"assertions": [
"a cold-parked split request acquires only the exact tab's background-mount lease and replays synchronously on lifecycle registration",
"a reminted numeric pane ID resolves through the stable source leaf while a missing or stale leaf fails closed",
"expired, canceled, closed-tab, and overflowed requests release their bounded queue and lease state"
]
},
{
"file": "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
"assertions": [
@@ -16927,45 +17096,128 @@
"assertions": [
"a cold-parked host pane carries a complete paired-client input and echo round trip"
]
},
{
"file": "tests/e2e/terminal-parked-cli-split.spec.ts",
"assertions": [
"the shipped dev CLI completes an exact parked-tab split before the historical 10-second timeout",
"only the parked target mounts under the bounded lease while the decoy worktree, tab, active leaf, and keyboard focus stay unchanged",
"the target re-parks, then reveals two visible panes with the stable source leaf, PTY, and handle intact",
"both the source and created pane accept scoped keyboard input and render independently generated output"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts",
"assertions": [
"a present pending surface cannot erase a verified binding when its prior handle is absent or still host-owned",
"ready replacement, positive PTY mismatch, orphan adoption, and authoritative removal remain distinct evidence states"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts",
"assertions": [
"stable unsupported adoption failures dedupe an identical claim frame",
"transport and queue-overload adoption failures retry on the same semantic snapshot",
"malformed adoption results retain the verified surface without unbounded RPC churn"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts",
"assertions": [
"one successful authoritative absence retains the last verified surface",
"two consecutive authoritative absences remove the missing surface",
"a healthy ready frame resets the absence confirmation"
]
},
{
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
"assertions": [
"eager worktree-switch and post-create snapshots pass through the same pairing-fenced recovery seam"
]
},
{
"file": "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts",
"assertions": [
"attach-only adoption registers one active session and exactly one history writer",
"a pre-restart checkpoint remains readable and later output appends to the same output log"
]
},
{
"file": "tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts",
"assertions": [
"a real headless runtime restart never empties the viewer's verified binding while the daemon PTY survives",
"rendered output, focused input, and resized grid delivery converge after the replacement runtime is ready",
"the original fixture process survives while its pre-restart checkpoint and post-restart history append remain durable",
"authoritative tab close removes the viewer binding"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-23",
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
"durationSeconds": 8,
"summary": "Three focused files passed 64 watcher, multi-pane runtime-graph, cold-park policy, and authoritative-stream tests."
"summary": "Fifteen deterministic files passed watcher, exact cold-parked split routing, multi-pane graph, pending-binding retention, exact adoption, mismatch quarantine, consecutive-absence confirmation, queue/cache bound, revision-fence, and eager-refresh tests."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts src/main/daemon/daemon-pty-adapter-history-recovery.test.ts --reporter=dot",
"durationSeconds": 4,
"summary": "Four daemon files passed 99 attach-only, legacy/current protocol, history registration, checkpoint, and append-continuity tests."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec playwright test tests/e2e/terminal-parked-cli-split.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"durationSeconds": 24,
"summary": "A real isolated-profile Electron run passed in 23.0 seconds: the shipped CLI split the exact cold-parked renderer tab before its historical timeout, only that tab mounted and released its background lease, the decoy selection and focus remained unchanged, and both stable-identity panes completed rendered keyboard/output round trips after reveal."
},
{
"date": "2026-08-29",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"durationSeconds": 36,
"summary": "A clean exact-source build passed the real pinned-port serve replacement in 27.4 seconds: the daemon PTY and fixture PID survived, the renderer observed pending-handle without an empty or divergent binding, output/input/resize converged, history checkpoint and output.log continuity held, and authoritative close removed the surface. An exact-bundle repeat also passed."
}
],
"runtimeBudget": {
"p95Seconds": 180,
"scope": "focused deterministic contracts plus one serial headed paired cold-park journey"
"p95Seconds": 360,
"scope": "focused deterministic contracts plus serial headed cold-park, parked CLI split, and headless runtime-restart paired journeys"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The existing sentinel is newly source-routed for PR collection; route-specific CI history has not started."
"evidence": "The cold-park, parked CLI split, and restart sentinels are source-routed for PR collection; route-specific CI history has not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo."
"evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo. Issue #17297 records the same-handle Windows A/B: the parked split failed after 11 seconds while an explicit switch made it immediate; the fixed macOS Electron journey completes before that 10-second internal timeout without a switch, preserves the decoy context, and renders independent round trips in both panes. Before the #12115 fix, the real restart delivered pending-handle and the viewer's tab identity rotated away from its retained layout binding; output still painted but post-restart keyboard output never reached output.log because attach-only adoption registered no active history writer. The fixed journey keeps one identity and appends history, while deterministic tests separate ready replacement, positive mismatch, safe retention, exact adoption, and removal."
},
"performanceBudget": {
"required": true,
"evidence": "Publication reads the existing bounded parked-watcher registry and adds no timer, polling, provider scan, subprocess, or wire field; the 90-second live timeline is selected only by exact parking and graph authorities."
"evidence": "Publication reads the existing bounded parked-watcher registry. Parked split routing retains at most 32 requests and 32 exact-tab leases, uses the existing three-second background mount window, cancels on close, and adds no polling or provider scan. Pending recovery is event-driven, capped at 64 exact candidates, and pairing-revision fenced. Each environment/revision/worktree key runs one active request plus only the latest trailing frame; the global RPC lane permits at most 4 active and 64 waiting calls; dispositions are capped at 512 fingerprints; ready/removal frames overtake degraded recovery. A repeated semantic snapshot version can retry a transient adoption failure without polling. The path adds no wire field, and its live journeys are selected only by exact parking, split-routing, recovery, stream, and daemon-attach authorities."
},
"promotionCriteria": [
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
"Collect Linux, Windows, WSL, and physical SSH cold-park evidence.",
"Keep exact per-leaf disposal, multi-pane identity, and full client round-trip assertions green."
"Collect Linux, Windows, WSL, and physical SSH cold-park/restart evidence.",
"Keep exact per-leaf disposal, multi-pane identity, pending-binding retention, and full client round-trip assertions green."
],
"knownGaps": [
"The live paired cold-park journey is macOS-only and uses a local daemon PTY.",
"Physical SSH, WSL, Linux, Windows, and host-restart cold-park journeys are not recorded."
"The live paired cold-park, parked CLI split, and runtime-restart journeys are macOS-only and use a local daemon PTY; the #17297 report itself was Windows 11.",
"Physical SSH, WSL, Linux, and Windows cold-park/restart journeys are not recorded.",
"Persisted-empty viewer rows recover through an exact host pane resolution when the host exposes a valid UUID leaf and matching connected PTY; legacy/malformed layouts or hosts without terminal.resolvePane remain pending until a newer authoritative snapshot.",
"A normal daemon-preserving runtime restart keeps the terminal handle stable; deterministic unit coverage, not the live restart, proves convergence to a distinct ready replacement handle and rejection of recycled-handle PTY mismatch."
],
"demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause."
"demotionRule": "Demote if a live parked/restarting PTY loses its exact graph leaf or verified viewer binding, a retired PTY remains published, multi-pane identity drifts, or either routed client round trip flakes without a diagnosed cause."
},
{
"id": "agent-browser.owner-boundary-cleanup",
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module'
import { electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const targetConfig = readFileSync('config/electron-vite-target.config.cts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
type BootstrapProcessMock = EventEmitter & {
@@ -109,6 +109,41 @@ describe('bundled skill guide generator', () => {
expect(source).toContain('name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"')
})
it.skipIf(process.platform === 'win32')(
'resolves snapshot cleanup through Orca user-data precedence',
async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
const assignment =
'orca_user_data_path="${ORCA_USER_DATA_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/orca}"'
expect(source).toContain(assignment)
const renderPath = async (env) =>
(
await execFileAsync(
'bash',
['-u', '-c', `${assignment}; printf '%s' "$orca_user_data_path"`],
{
env
}
)
).stdout
await expect(renderPath({ HOME: '/home/orca' })).resolves.toBe('/home/orca/.config/orca')
await expect(
renderPath({ HOME: '/home/orca', XDG_CONFIG_HOME: '/srv/config' })
).resolves.toBe('/srv/config/orca')
await expect(
renderPath({
HOME: '/home/orca',
XDG_CONFIG_HOME: '/srv/config',
ORCA_USER_DATA_PATH: '/var/lib/orca-custom'
})
).resolves.toBe('/var/lib/orca-custom')
}
)
it.skipIf(process.platform === 'win32')(
'keeps Vercel sandbox names valid while preserving the instance suffix',
async () => {
@@ -315,6 +315,9 @@ describe('PR E2E gate contract', () => {
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-target-sync-test-harness.ts'])
).toEqual([])
})
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
@@ -464,6 +467,50 @@ describe('PR E2E gate contract', () => {
expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
}
const parkedSplitSpec = 'tests/e2e/terminal-parked-cli-split.spec.ts'
for (const source of [
'src/main/window/attach-main-window-services.ts',
'src/preload/api/ui-command-event-api.ts',
'src/preload/index.ts',
'src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.ts',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts',
'src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts'
]) {
expect(selectPrE2eSpecs([source]), source).toContain(parkedSplitSpec)
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
parkedSplitSpec
)
}
expect(existsSync(join(projectDir, parkedSplitSpec)), parkedSplitSpec).toBe(true)
const restartContinuitySpec = 'tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts'
for (const source of [
'src/main/daemon/daemon-attach-only-retirement.ts',
'src/main/daemon/daemon-pty-applied-size.ts',
'src/main/daemon/daemon-pty-session-control.ts',
'src/main/daemon/daemon-pty-spawn-result.ts',
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts',
'src/renderer/src/components/terminal-pane/terminal-error-accumulation.ts',
'src/renderer/src/runtime/web-runtime-session.ts',
'src/renderer/src/runtime/web-session-tabs-sync.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-inventory-validation.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-cache.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-pane.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-queue.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-recovery-rpc-lane.ts',
'src/renderer/src/runtime/web-session-terminal-orphan-topology.ts'
]) {
expect(selectPrE2eSpecs([source]), source).toContain(restartContinuitySpec)
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
restartContinuitySpec
)
}
expect(existsSync(join(projectDir, restartContinuitySpec)), restartContinuitySpec).toBe(true)
const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
for (const source of [
'src/renderer/src/components/terminal-pane/pty-connection.ts',
+19
View File
@@ -52,6 +52,7 @@ export const PR_E2E_SOURCE_ROUTES = [
],
matches: (file) =>
isProductSource(file) &&
!file.endsWith('-test-harness.ts') &&
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
@@ -112,6 +113,24 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'terminal-session.parked-cli-split',
specs: ['tests/e2e/terminal-parked-cli-split.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/window\/attach-main-window-services\.ts|src\/preload\/(?:index|api\/ui-command-event-api)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-pane-split-request-routing|use-terminal-pane-lifecycle|use-terminal-tab-cold-parking)\.ts|src\/renderer\/src\/hooks\/ipc-events\/terminal-ui-routing-ipc-bridge\.ts)$/.test(
file
)
},
{
id: 'terminal-session.paired-serve-restart-binding-continuity',
specs: ['tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/daemon\/(?:daemon-attach-only-retirement|daemon-pty-applied-size|daemon-pty-session-control|daemon-pty-spawn-result)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:remote-runtime-pty-transport|terminal-error-accumulation)\.ts|src\/renderer\/src\/runtime\/(?:web-runtime-session|web-session-tabs-sync|web-session-terminal-orphan-(?:topology|recovery(?:-(?:adoption|surface|inventory|inventory-validation|cache|queue|rpc-lane|pane))?))\.ts)$/.test(
file
)
},
{
id: 'terminal-provider.ssh-remote-reattach-contract',
specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
@@ -1,4 +1,4 @@
import { globSync, readFileSync } from 'node:fs'
import { existsSync, globSync, readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
@@ -59,6 +59,9 @@ describe('PR workflow parallelism', () => {
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
@@ -80,6 +83,9 @@ describe('PR workflow parallelism', () => {
expect(primerInstall.with['native-runtime']).toBe('node')
expect(primerInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
expect(nodeNextPrimerInstall.with['node-version']).toBe('26')
expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache'])
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -177,6 +183,15 @@ describe('PR workflow parallelism', () => {
// Why this file is excluded: it carries the detector pattern as a literal
// and would otherwise match itself.
.filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs')
// TypeScript builds can leave an ignored JavaScript companion beside a source
// test. Inspect the source file once so generated output cannot duplicate it.
.filter(
(testFile) =>
!testFile.endsWith('.js') ||
!['.ts', '.tsx', '.mjs', '.cjs'].some((extension) =>
existsSync(testFile.replace(/\.js$/, extension))
)
)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
.sort()
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
// Keep this wrapper CommonJS (the `.cts` extension) so electron-vite can load
// each parallel target without sharing its timestamp-named ESM temp file.
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.cts', import.meta.url))
const targets = ['main', 'preload', 'renderer']
function buildTarget(target) {
+3 -3
View File
@@ -115,7 +115,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
node-pty@1.1.0: 572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e
importers:
@@ -156,7 +156,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
version: 1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12194,7 +12194,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
node-pty@1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e):
dependencies:
node-addon-api: 7.1.1
+4 -2
View File
@@ -140,8 +140,10 @@ shape is §7a; key points:
booted from it: the pairing keypair and device-token registry (`orca-devices.json`,
`orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history
and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and
`pairedDeviceId`**. Snapshot **before** the runtime has ever run, or `rm -rf` the whole user-data dir
(`~/.config/orca` on Linux) first — deleting a named file list will drift as Orca adds state.
`pairedDeviceId`**. Snapshot **before** the runtime has ever run, or delete the resolved user-data
directory first: `orca_user_data_path="${ORCA_USER_DATA_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/orca}"; rm -rf -- "$orca_user_data_path"`.
This matches Orca's Linux precedence for custom and default paths; deleting a named file list will
drift as Orca adds state.
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.
---
File diff suppressed because one or more lines are too long
+71 -2
View File
@@ -10,13 +10,18 @@ const ORIGINAL_EXIT_CODE = process.exitCode
describe('terminal close CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
process.exitCode = ORIGINAL_EXIT_CODE
})
it('keeps the default close RPC unchanged', async () => {
process.exitCode = undefined
const call = vi.fn().mockResolvedValue({
result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } }
id: 'req-close',
ok: true,
result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } },
_meta: { runtimeId: 'runtime-1' }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal close']({
flags: new Map([['terminal', 'term-1']]),
@@ -26,9 +31,72 @@ describe('terminal close CLI', () => {
})
expect(call).toHaveBeenCalledWith('terminal.close', { terminal: 'term-1' })
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
ok: true,
result: { close: { ptyKilled: true } }
})
expect(process.exitCode).toBeUndefined()
})
it('reports an unverifiable PTY stop as a failing JSON outcome', async () => {
process.exitCode = undefined
const close = {
handle: 'term-remote',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'unverifiable' as const,
ptyStopReason: 'its SSH provider is no longer registered'
}
const call = vi.fn().mockResolvedValue({
id: 'req-close',
ok: true,
result: { close },
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal close']({
flags: new Map([['terminal', close.handle]]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'terminal_stop_unverifiable',
message: expect.stringContaining('unverifiable'),
data: { close }
}
})
expect(process.exitCode).toBe(1)
})
it('reports a live PTY stop as a failing human outcome', async () => {
process.exitCode = undefined
const close = {
handle: 'term-live',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'live' as const
}
const call = vi.fn().mockResolvedValue({ result: { close } })
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal close']({
flags: new Map([['terminal', close.handle]]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: false
})
expect(log).toHaveBeenCalledWith(expect.stringContaining('The PTY is live.'))
expect(process.exitCode).toBe(1)
})
it('routes --tab to the durable whole-tab RPC', async () => {
process.exitCode = undefined
const parsed = parseArgs(['terminal', 'close', '--terminal', 'term-1', '--tab'])
const call = vi.fn().mockResolvedValue({
result: {
@@ -51,6 +119,7 @@ describe('terminal close CLI', () => {
expect(parsed.flags.get('tab')).toBe(true)
expect(call).toHaveBeenCalledWith('terminal.closeTab', { terminal: 'term-1' })
expect(process.exitCode).toBeUndefined()
})
it('documents that --tab waits for durable persistence', () => {
+33
View File
@@ -23,6 +23,7 @@ import {
formatTerminalShow,
formatTerminalSplit,
formatTerminalWait,
reportCliError,
printResult
} from '../format'
import {
@@ -43,6 +44,24 @@ import {
// long waits instead of failing at the generic 15s transport cap.
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
/** A false stop receipt is an error only when the host supplied a liveness verdict. */
function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError | null {
if (close.ptyKilled || close.ptyStopVerdict === undefined) {
return null
}
const verdict = close.ptyStopVerdict
const detail =
verdict === 'live'
? 'The PTY is live.'
: `The PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its host could not be reached'}.`
return new RuntimeClientError(
verdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
`Terminal ${close.handle} close failed to confirm the PTY stopped (${verdict}). ${detail}`,
{ close }
)
}
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
terminal: await getTerminalHandle(flags, cwd, client),
@@ -183,6 +202,20 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
terminal: await getTerminalHandle(flags, cwd, client)
})
// Why: a transport-level success must not hide a live or unverifiable PTY. Keep the receipt in
// error.data so JSON callers retain the host's exact evidence while receiving a failing outcome.
const failure = terminalCloseFailure(result.result.close)
if (failure) {
// Keep the established human receipt (including its liveness warning); JSON needs the
// standard failure envelope so callers do not mistake transport success for a stopped PTY.
if (json) {
reportCliError(failure, true)
} else {
printResult(result, false, formatTerminalClose)
}
process.exitCode = 1
return
}
printResult(result, json, formatTerminalClose)
},
'terminal split': async ({ flags, client, cwd, json }) => {
@@ -49,13 +49,15 @@ function createPlatformAssertion() {
function createService(
blocker = createBlocker(),
macosAssertion = createPlatformAssertion(),
linuxAssertion = createPlatformAssertion()
linuxAssertion = createPlatformAssertion(),
platform: NodeJS.Platform = 'linux'
): AgentAwakeService {
return new AgentAwakeService({
blocker,
linuxAssertion,
macosAssertion,
now: () => 1_000,
platform,
powerMonitor: null,
logger: {
debug: vi.fn(),
@@ -65,6 +67,18 @@ function createService(
}
describe('AgentAwakeService platform assertions', () => {
it('uses caffeinate without Electron display blocking on macOS', () => {
const blocker = createBlocker()
const macosAssertion = createPlatformAssertion()
const service = createService(blocker, macosAssertion, createPlatformAssertion(), 'darwin')
service.setEnabled(true)
service.setStatuses([workingStatus()])
expect(macosAssertion.start).toHaveBeenCalledTimes(1)
expect(blocker.start).not.toHaveBeenCalled()
})
it('keeps Electron blocker active when macOS assertion start fails', () => {
const blocker = createBlocker()
const macosAssertion = createPlatformAssertion()
@@ -72,7 +86,7 @@ describe('AgentAwakeService platform assertions', () => {
macosAssertion.start.mockImplementation(() => {
throw new Error('caffeinate failed')
})
const service = createService(blocker, macosAssertion, linuxAssertion)
const service = createService(blocker, macosAssertion, linuxAssertion, 'darwin')
service.setEnabled(true)
service.setStatuses([workingStatus()])
@@ -85,6 +99,20 @@ describe('AgentAwakeService platform assertions', () => {
expect(linuxAssertion.stop).toHaveBeenCalled()
})
it('drops the display-blocking fallback after caffeinate recovers', () => {
const blocker = createBlocker()
const macosAssertion = createPlatformAssertion()
macosAssertion.start.mockImplementationOnce(() => false).mockImplementation(() => true)
const service = createService(blocker, macosAssertion, createPlatformAssertion(), 'darwin')
service.setEnabled(true)
service.setStatuses([workingStatus()])
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
service.setStatuses([{ ...workingStatus(), receivedAt: 1_001 }])
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('keeps Electron blocker active when Linux assertion start fails', () => {
const blocker = createBlocker()
const macosAssertion = createPlatformAssertion()
+1
View File
@@ -85,6 +85,7 @@ function createService(
linuxAssertion,
macosAssertion,
now,
platform: 'linux',
powerMonitor,
logger: {
debug: vi.fn(),
+13 -5
View File
@@ -23,7 +23,7 @@ type PowerSaveBlocker = {
}
type PlatformAwakeAssertion = {
start: (reason: string) => void
start: (reason: string) => boolean | void
stop: (reason: string) => void
dispose: () => void
}
@@ -41,6 +41,7 @@ type AgentAwakeServiceOptions = {
logger?: Logger
macosAssertion?: PlatformAwakeAssertion
now?: () => number
platform?: NodeJS.Platform
powerMonitor?: PowerMonitorEventSource | null
}
@@ -55,6 +56,7 @@ export class AgentAwakeService {
private readonly linuxAssertion: PlatformAwakeAssertion
private readonly logger: Logger
private readonly macosAssertion: PlatformAwakeAssertion
private readonly platform: NodeJS.Platform
private readonly now: () => number
private readonly unsubscribeResume: (() => void) | null
@@ -78,6 +80,7 @@ export class AgentAwakeService {
now: this.now,
onUnexpectedFailure: (reason) => this.refresh(reason)
})
this.platform = options.platform ?? process.platform
const resumeSource = options.powerMonitor === undefined ? powerMonitor : options.powerMonitor
if (resumeSource) {
const onResume = () => this.refresh('power-resume')
@@ -132,8 +135,12 @@ export class AgentAwakeService {
const runningStatusCount = this.getEligibleRunningStatusCount()
const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0)
if (shouldBlock) {
this.startBlocker(reason, runningStatusCount)
this.startMacosAssertion(reason)
const macosAssertionActive = this.startMacosAssertion(reason)
if (this.platform !== 'darwin' || !macosAssertionActive) {
this.startBlocker(reason, runningStatusCount)
} else {
this.stopBlocker('macos-assertion-active', runningStatusCount)
}
this.startLinuxAssertion(reason)
} else {
this.stopBlocker(reason, runningStatusCount)
@@ -229,15 +236,16 @@ export class AgentAwakeService {
}
}
private startMacosAssertion(reason: string): void {
private startMacosAssertion(reason: string): boolean {
try {
this.macosAssertion.start(reason)
return this.macosAssertion.start(reason) !== false
} catch (err) {
this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', {
reason,
mode: this.mode,
error: err
})
return false
}
}
@@ -22,19 +22,23 @@ describe('estimateCostUsd cache-write TTL rates', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01)
})
it('applies the long-context tier to 1-hour writes', () => {
expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6)
it('keeps Sonnet 4.6 one-hour writes flat across its full 1M window', () => {
expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(2.4)
})
it('shares one long-context allowance across both TTL buckets', () => {
it('applies the legacy long-context tier to Sonnet 4.5 one-hour writes', () => {
expect(estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6)
})
it('shares one legacy long-context allowance across both TTL buckets', () => {
// 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier
// getting half of the 200k allowance.
expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925)
expect(estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925)
})
it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => {
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map((write1h) =>
estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
it('never lowers a legacy long-context estimate as writes shift to 1-hour', () => {
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map(
(write1h) => estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, write1h)!
)
for (let index = 1; index < costs.length; index++) {
expect(costs[index]).toBeGreaterThan(costs[index - 1])
@@ -37,13 +37,13 @@ const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
// Claude 4.6 and later keep standard rates across the full 1M context window.
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
cacheWrite1h: 6
},
'claude-sonnet-4-5': {
input: 3,
+2 -2
View File
@@ -546,7 +546,7 @@ describe('ClaudeUsageStore', () => {
expect(summary.estimatedCostUsd).toBeCloseTo(220.5)
})
it('prices Sonnet long-context usage with threshold rates', async () => {
it('prices Sonnet 4.6 long-context usage at its flat 1M-window rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
@@ -569,7 +569,7 @@ describe('ClaudeUsageStore', () => {
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(8.07)
expect(summary.estimatedCostUsd).toBeCloseTo(6.615)
})
it('returns automation usage for a single matching worktree session', async () => {
@@ -0,0 +1,19 @@
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
export async function retireUnexpectedAttachOnlySpawn(
sessionId: string,
retire: () => Promise<unknown>
): Promise<void> {
try {
await retire()
} catch (error) {
if (error instanceof SessionNotFoundError) {
return
}
console.warn('[daemon] attach-only retire of unexpected spawn failed', {
sessionId,
error
})
throw new TerminalSessionOwnerUnverifiedError(sessionId)
}
}
+2 -9
View File
@@ -25,6 +25,7 @@ import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
import { publishDaemonPidFile } from './daemon-spawner'
import { isNativePtyException } from './daemon-native-pty-exception'
export type ParsedDaemonArgs = {
socketPath: string
@@ -149,15 +150,7 @@ async function main(): Promise<void> {
// crash the daemon — masking those would hide real issues.
process.on('uncaughtException', (err) => {
const msg = err?.message ?? ''
const isNativeError =
err?.name === 'Error' &&
(msg.includes('pty') ||
msg.includes('Pty') ||
msg.includes('EIO') ||
msg.includes('EPIPE') ||
msg.includes('EBADF') ||
msg.includes('ENXIO'))
if (isNativeError) {
if (isNativePtyException(err)) {
daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg })
console.error('[daemon] Native PTY exception (suppressed):', err)
return
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { isNativePtyException } from './daemon-native-pty-exception'
describe('isNativePtyException', () => {
it.each([
Object.assign(new Error('write EAGAIN'), {
code: 'EAGAIN',
stack: 'Error: write EAGAIN\n at node-pty/lib/windowsTerminal.js:1:1'
}),
Object.assign(new Error('read EIO'), {
stack: 'Error: read EIO\n at /app/node_modules/node-pty/lib/unixTerminal.js:1:1'
}),
new Error('Pty process exited'),
new Error('Invalid pty handle'),
new Error('ioctl(2) failed, EBADF'),
Object.assign(new Error('native write failed'), {
code: 'EPIPE',
stack: 'Error: native write failed\n at node-pty/lib/windowsTerminal.js:1:1'
})
])('contains native PTY failures without killing the daemon', (error) => {
expect(isNativePtyException(error)).toBe(true)
})
it.each([
new Error('database invariant failed'),
new Error('database write EAGAIN'),
new Error('write EPIPE'),
new Error('pty metadata invariant failed'),
new Error('node-pty metadata invariant failed'),
Object.assign(new Error('database write failed'), { code: 'EAGAIN' }),
Object.assign(new Error('socket write failed'), { code: 'EPIPE' }),
new TypeError('logic bug'),
'EAGAIN'
])('does not suppress unrelated or malformed failures', (error) => {
expect(isNativePtyException(error)).toBe(false)
})
})
@@ -0,0 +1,16 @@
const NATIVE_PTY_ERROR_CODE_PATTERN = /\b(?:EIO|EPIPE|EBADF|ENXIO|EAGAIN)\b/
const NATIVE_PTY_MESSAGE_PATTERN =
/^(?:Pty process exited|Invalid pty handle|Cannot resize a pty that has already exited|ioctl\(2\) failed(?:, (?:EBADF|EFAULT|EINVAL|ENOTTY))?)$/i
const NODE_PTY_STACK_PATTERN = /\bnode-pty[\\/]/i
export function isNativePtyException(error: unknown): boolean {
if (!(error instanceof Error) || error.name !== 'Error') {
return false
}
const code = 'code' in error && typeof error.code === 'string' ? error.code : null
return (
NATIVE_PTY_MESSAGE_PATTERN.test(error.message) ||
(NODE_PTY_STACK_PATTERN.test(error.stack ?? '') &&
NATIVE_PTY_ERROR_CODE_PATTERN.test(code ?? error.message))
)
}
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import type { DaemonServer } from './daemon-server'
import { HeadlessEmulator } from './headless-emulator'
import { getHistorySessionDirName } from './history-paths'
@@ -13,6 +14,7 @@ import {
} from './daemon-pty-adapter-test-harness'
import type * as DaemonHealthModule from './daemon-health'
import type * as DaemonTccAttributionModule from './daemon-tcc-attribution'
import type { TerminalSnapshot } from './types'
const { getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock } = vi.hoisted(
() => ({
@@ -217,6 +219,123 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(true)
})
it('re-anchors and resumes history after attach-only adoption', async () => {
const sessionId = 'attach-only-history-adoption'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const firstData: string[] = []
first.onData(({ data }) => firstData.push(data))
await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId })
lastSubprocess._simulateData('BASELINE-BEFORE-RESTART\r\n')
await waitFor(() => firstData.includes('BASELINE-BEFORE-RESTART\r\n'))
const firstInternals = first as unknown as {
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
}
await firstInternals.checkpointSessions([sessionId])
expect(readFileSync(join(sessionDir, 'output.log')).includes('BASELINE-BEFORE-RESTART')).toBe(
true
)
await first.disconnectOnly()
expect(
JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf8')).snapshotAnsi
).toContain('BASELINE-BEFORE-RESTART')
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const attachedData: string[] = []
historyAdapter.onData(({ data }) => attachedData.push(data))
await historyAdapter.attach(sessionId)
await historyAdapter.attach(sessionId)
const manager = historyAdapter.getHistoryManager()!
const managerInternals = manager as unknown as { writers: Map<string, unknown> }
expect(historyAdapter.getActiveSessionIds()).toEqual([sessionId])
expect(manager.hasWriter(sessionId)).toBe(true)
expect([...managerInternals.writers]).toHaveLength(1)
const attachedInternals = historyAdapter as unknown as {
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
}
expect(
JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf8')).snapshotAnsi
).toContain('BASELINE-BEFORE-RESTART')
lastSubprocess._simulateData('FIRST-AFTER-RESTART\r\n')
await waitFor(() => attachedData.includes('FIRST-AFTER-RESTART\r\n'))
await attachedInternals.checkpointSessions([sessionId])
expect(readFileSync(join(sessionDir, 'output.log')).includes('FIRST-AFTER-RESTART')).toBe(
true
)
lastSubprocess._simulateData('SECOND-AFTER-RESTART\r\n')
await waitFor(() => attachedData.includes('SECOND-AFTER-RESTART\r\n'))
await attachedInternals.checkpointSessions([sessionId])
const appendedLog = readFileSync(join(sessionDir, 'output.log'))
expect(appendedLog.includes('FIRST-AFTER-RESTART')).toBe(true)
expect(appendedLog.includes('SECOND-AFTER-RESTART')).toBe(true)
await historyAdapter.shutdown(sessionId, { immediate: true })
expect(historyAdapter.getActiveSessionIds()).toEqual([])
expect(manager.hasWriter(sessionId)).toBe(false)
})
it('does not route an exact incarnation that exits during attach history overlay', async () => {
const sessionId = 'attach-overlay-exit-race'
const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const initial = await first.spawn({ cols: 80, rows: 24, sessionId })
await first.disconnectOnly()
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const overlayTarget = historyAdapter as unknown as {
overlayDurableRestoreSnapshot(
id: string,
snapshot: TerminalSnapshot
): Promise<TerminalSnapshot>
}
const originalOverlay = overlayTarget.overlayDurableRestoreSnapshot.bind(historyAdapter)
let reportOverlayReady!: () => void
const overlayReady = new Promise<void>((resolve) => {
reportOverlayReady = resolve
})
let releaseOverlay!: () => void
const overlayRelease = new Promise<void>((resolve) => {
releaseOverlay = resolve
})
vi.spyOn(overlayTarget, 'overlayDurableRestoreSnapshot').mockImplementation(
async (id, snapshot) => {
const result = await originalOverlay(id, snapshot)
reportOverlayReady()
await overlayRelease
return result
}
)
const router = new DaemonPtyRouter({ current: historyAdapter, legacy: [] })
const exits: { id: string; incarnationId?: string }[] = []
router.onExit((event) => exits.push(event))
const spawning = router.spawn({ cols: 80, rows: 24, sessionId, attachOnly: true })
await overlayReady
lastSubprocess._simulateExit(0)
await waitFor(() => exits.some((event) => event.incarnationId === initial.incarnationId))
releaseOverlay()
const result = await spawning
expect(result).toMatchObject({
id: sessionId,
incarnationId: initial.incarnationId,
exitedBeforeSpawnReply: true,
isReattach: true
})
const routerInternals = router as unknown as {
sessionAdapters: Map<string, DaemonPtyAdapter>
}
expect(routerInternals.sessionAdapters.has(sessionId)).toBe(false)
expect(historyAdapter.getActiveSessionIds()).toEqual([])
expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(false)
router.disposeRouterOnly()
})
it('does not probe session aliveness when there is no restorable history', async () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const client = (
@@ -577,6 +577,128 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
describe('attach applied-size compatibility', () => {
function mockAttachRequest(args: {
sessionId: string
cols: number
rows: number
protocolVersion: number
getSizeError?: Error
getSizeResponse?: { size: { cols: number; rows: number } | null }
}): {
request: ReturnType<typeof vi.spyOn>
ensureConnected: ReturnType<typeof vi.spyOn>
adapter: DaemonPtyAdapter
} {
const ensureConnected = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const request = vi
.spyOn(DaemonClient.prototype, 'request')
.mockImplementation(async (type: string) => {
if (type === 'getSize') {
if (args.getSizeError) {
throw args.getSizeError
}
return (args.getSizeResponse ?? { size: null }) as never
}
if (type === 'listSessions') {
return {
sessions: [
{
sessionId: args.sessionId,
isAlive: true,
cols: args.cols,
rows: args.rows
}
]
} as never
}
if (type === 'createOrAttach') {
return {
isNew: false,
snapshot: null,
pid: 4321,
shellState: 'unsupported',
incarnationId: 'compat-attach-incarnation'
} as never
}
return {} as never
})
const adapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
protocolVersion: args.protocolVersion
})
return { request, ensureConnected, adapter }
}
it('uses inventory dimensions when attaching to a pre-getSize daemon', async () => {
const sessionId = 'legacy-v17-session'
const rig = mockAttachRequest({
sessionId,
cols: 137,
rows: 41,
protocolVersion: GET_SIZE_PROTOCOL_VERSION - 1
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
expect(rig.request).not.toHaveBeenCalledWith('getSize', expect.anything())
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
'createOrAttach',
expect.objectContaining({ sessionId, cols: 137, rows: 41 })
)
} finally {
rig.adapter.dispose()
rig.request.mockRestore()
rig.ensureConnected.mockRestore()
}
})
it('falls back to inventory when a versioned daemon rejects getSize', async () => {
const sessionId = 'ambiguous-get-size-session'
const rig = mockAttachRequest({
sessionId,
cols: 120,
rows: 30,
protocolVersion: GET_SIZE_PROTOCOL_VERSION,
getSizeError: new Error('Unknown request type: getSize')
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
'createOrAttach',
expect.objectContaining({ sessionId, cols: 120, rows: 30 })
)
} finally {
rig.adapter.dispose()
rig.request.mockRestore()
rig.ensureConnected.mockRestore()
}
})
it('preserves a size-probe transport failure as unverifiable', async () => {
const transportError = new Error('Connection lost')
const rig = mockAttachRequest({
sessionId: 'disconnected-attach-session',
cols: 80,
rows: 24,
protocolVersion: GET_SIZE_PROTOCOL_VERSION,
getSizeError: transportError
})
try {
await expect(rig.adapter.attach('disconnected-attach-session')).rejects.toBe(transportError)
expect(rig.request).not.toHaveBeenCalledWith('createOrAttach', expect.anything())
} finally {
rig.adapter.dispose()
rig.request.mockRestore()
rig.ensureConnected.mockRestore()
}
})
})
describe('inspectProcess on pre-inspection daemon protocols', () => {
type ClientInternals = {
client: { request: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import {
createMockSubprocess,
startDaemonAdapterHarness,
waitFor
} from './daemon-pty-adapter-test-harness'
describe('DaemonPtyAdapter replacement exit races', () => {
let adapter: DaemonPtyAdapter
let server: Awaited<ReturnType<typeof startDaemonAdapterHarness>>['server']
let tempDir: string
let lastSubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
const harness = await startDaemonAdapterHarness(() => {
lastSubprocess = createMockSubprocess()
return lastSubprocess
})
adapter = harness.adapter
server = harness.server
tempDir = harness.dir
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('captures a replacement exit while the crashed daemon incarnation is still cached', async () => {
const sessionId = 'replacement-exit-with-stale-incarnation-cache'
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
pendingSpawnOperationsBySessionId: Map<
string,
Set<{ exitsBySessionId: Map<string, unknown[]> }>
>
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
internals.activeSessionIds.add(sessionId)
internals.sessionIncarnations.set(sessionId, 'incarnation-from-crashed-daemon')
const originalRequest = internals.client.request.bind(internals.client)
vi.spyOn(internals.client, 'request').mockImplementation(
async (type: string, payload?: unknown) => {
const response = await originalRequest(type, payload)
if (type === 'createOrAttach') {
lastSubprocess._simulateExit(19)
await waitFor(() =>
[...(internals.pendingSpawnOperationsBySessionId.get(sessionId) ?? [])].some(
(operation) => (operation.exitsBySessionId.get(sessionId)?.length ?? 0) > 0
)
)
}
return response
}
)
const result = await adapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result).toMatchObject({
incarnationId: expect.any(String),
exitedBeforeSpawnReply: true
})
expect(internals.activeSessionIds.has(sessionId)).toBe(false)
expect(internals.sessionIncarnations.has(sessionId)).toBe(false)
expect(internals.pendingSpawnOperationsBySessionId.has(sessionId)).toBe(false)
})
})
@@ -5,6 +5,7 @@ import { rmSync, writeFileSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
import type { HistoryReader } from './history-reader'
import type { DaemonFileLog } from './daemon-file-log'
import { serializeDaemonPidFile } from './daemon-spawner'
@@ -315,6 +316,34 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
adapter2.dispose()
})
it('refuses an attach whose exit event beats the control reply', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const adapter2 = new DaemonPtyAdapter({ socketPath, tokenPath })
const exits: string[] = []
adapter2.onExit(({ id: exitedId }) => exits.push(exitedId))
const client = (
adapter2 as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const request = client.request.bind(client)
vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
const result = await request(type, payload)
if (type === 'createOrAttach') {
lastSubprocess._simulateExit(0)
await waitFor(() => exits.includes(id))
}
return result
})
try {
await expect(adapter2.attach(id)).rejects.toThrow(`Session not found: ${id}`)
expect(adapter2.getActiveSessionIds()).toEqual([])
} finally {
adapter2.dispose()
}
})
it('keeps legacy attach behavior when no output sequence is available', async () => {
const ensureConnected = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
@@ -354,6 +383,41 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
adapter2.dispose()
})
it('retires a fresh session returned by a current daemon for attach-only', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi
.spyOn(DaemonClient.prototype, 'request')
.mockImplementation(async (type: string) =>
type === 'getSize'
? ({ size: { cols: 100, rows: 30 } } as never)
: type === 'createOrAttach'
? ({ isNew: true, pid: 77, shellState: 'unsupported', snapshot: null } as never)
: ({} as never)
)
const current = new DaemonPtyAdapter({ socketPath, tokenPath })
try {
await expect(current.attach('raced-current-session')).rejects.toThrow(
'Session not found: raced-current-session'
)
expect(requestSpy).toHaveBeenCalledWith(
'createOrAttach',
expect.objectContaining({ cols: 100, rows: 30, attachOnly: true })
)
expect(requestSpy).toHaveBeenCalledWith('kill', {
sessionId: 'raced-current-session',
immediate: true
})
expect(current.getActiveSessionIds()).toEqual([])
} finally {
current.dispose()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
it('retires the accidental spawn of a pre-v31 daemon that ignores attachOnly', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
@@ -375,8 +439,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(requestSpy).toHaveBeenCalledWith(
'createOrAttach',
expect.objectContaining({ cols: 100, rows: 30, attachOnly: true })
expect.objectContaining({ cols: 100, rows: 30 })
)
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
expect(createPayload).not.toHaveProperty('attachOnly')
expect(requestSpy).toHaveBeenCalledWith('kill', {
sessionId: 'raced-legacy-session',
immediate: true
@@ -388,7 +454,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it('surfaces a failed retire of the accidental legacy spawn instead of swallowing it', async () => {
it('keeps a failed retire of the accidental legacy spawn unverifiable', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
@@ -406,13 +472,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
await expect(legacy.attach('orphaned-legacy-session')).rejects.toThrow(
'Session not found: orphaned-legacy-session'
await expect(legacy.attach('orphaned-legacy-session')).rejects.toBeInstanceOf(
TerminalSessionOwnerUnverifiedError
)
// The orphaned replacement is at least diagnosable.
expect(warnSpy).toHaveBeenCalledWith(
'[daemon] attach-only retire of accidental legacy spawn failed',
'[daemon] attach-only retire of unexpected spawn failed',
expect.objectContaining({ sessionId: 'orphaned-legacy-session' })
)
} finally {
@@ -743,6 +743,97 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
cause: { kind: 'exited', exitCode: 42 }
})
})
it('does not let an untagged stale-write exit clear a known replacement', async () => {
// The daemon-request-router emits this compatibility exit without an incarnation
// when a fire-and-forget write targets a session it no longer owns.
await adapter.spawn({ cols: 80, rows: 24 })
const sessionId = 'replacement-known-before-untagged-exit'
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
client: { onEvent: (listener: (event: unknown) => void) => () => void }
}
internals.activeSessionIds.add(sessionId)
internals.sessionIncarnations.set(sessionId, 'incarnation-new')
const exits: { id: string; code: number }[] = []
adapter.onExit((payload) => exits.push(payload))
const rawEvents: unknown[] = []
const removeRawListener = internals.client.onEvent((event) => rawEvents.push(event))
try {
expect(adapter.write(sessionId, 'stale-input')).toBe(true)
await waitFor(() =>
rawEvents.some(
(event) =>
typeof event === 'object' &&
event !== null &&
(event as { event?: string }).event === 'exit'
)
)
} finally {
removeRawListener()
}
expect(exits).toEqual([])
expect(internals.activeSessionIds.has(sessionId)).toBe(true)
expect(internals.sessionIncarnations.get(sessionId)).toBe('incarnation-new')
})
it('requires incarnation proof when matching an exit received before a spawn reply', () => {
const sessionId = 'spawn-reply-incarnation-proof'
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
resultForExitBeforeSpawnReply: (...args: unknown[]) => unknown
}
internals.activeSessionIds.add(sessionId)
internals.sessionIncarnations.set(sessionId, 'incarnation-new')
const operation = {
exitsBySessionId: new Map([[sessionId, [{ code: -1 }]]]),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false
}
const result = {
isNew: true,
snapshot: null,
pid: null,
shellState: 'unsupported',
incarnationId: 'incarnation-new'
}
expect(internals.resultForExitBeforeSpawnReply(sessionId, result, operation)).toBeNull()
expect(internals.activeSessionIds.has(sessionId)).toBe(true)
expect(internals.sessionIncarnations.get(sessionId)).toBe('incarnation-new')
})
it('does not treat an untagged exit as replacement proof when a generation is known', () => {
const sessionId = 'spawn-reply-untagged-replacement'
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
resultForExitBeforeSpawnReply: (...args: unknown[]) => unknown
}
internals.activeSessionIds.add(sessionId)
internals.sessionIncarnations.set(sessionId, 'incarnation-before-retry')
const operation = {
exitsBySessionId: new Map([[sessionId, [{ code: 17 }]]]),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false
}
const result = {
isNew: true,
snapshot: null,
pid: null,
shellState: 'unsupported'
}
expect(internals.resultForExitBeforeSpawnReply(sessionId, result, operation)).toBeNull()
expect(internals.activeSessionIds.has(sessionId)).toBe(true)
expect(internals.sessionIncarnations.get(sessionId)).toBe('incarnation-before-retry')
})
})
describe('serialize / revive', () => {
+14 -29
View File
@@ -64,6 +64,7 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro
fact: event.payload
})
} else if (event.event === 'exit') {
const currentIncarnationId = this.sessionIncarnations.get(event.sessionId)
const pendingOperations = new Set([
...(this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? []),
...this.pendingClaimSpawnOperations
@@ -75,43 +76,27 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro
}
const exits = operation.exitsBySessionId.get(event.sessionId) ?? []
exits.push(
event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}
event.payload.incarnationId
? { code: event.payload.code, incarnationId: event.payload.incarnationId }
: { code: event.payload.code }
)
operation.exitsBySessionId.set(event.sessionId, exits)
}
const currentIncarnationId = this.sessionIncarnations.get(event.sessionId)
// Keep a raced exit available to the in-flight spawn even when the
// adapter still remembers the predecessor's generation. Only the
// generation currently published by this adapter may clear state or
// notify listeners.
if (
event.payload.incarnationId &&
currentIncarnationId &&
currentIncarnationId !== undefined &&
event.payload.incarnationId !== currentIncarnationId
) {
return
}
this.activeSessionIds.delete(event.sessionId)
this.clearSessionAwaitingDaemonRecovery(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
this.pausedProducerSessionIds.delete(event.sessionId)
this.producerResumesOwedOnReconnect.delete(event.sessionId)
this.backgroundedSessionIds.delete(event.sessionId)
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
this.coldRestoreCache.delete(event.sessionId)
}
this.sessionsNeedingFullCheckpoint.delete(event.sessionId)
this.sessionsNeedingLiveCheckpoint.delete(event.sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId)
this.overlayDeadlineWarnedSessionIds.delete(event.sessionId)
this.periodicDeadlineWarnedSessionIds.delete(event.sessionId)
this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId)
this.lastFullCheckpointAt.delete(event.sessionId)
this.stopCheckpointTimerIfIdle()
if (this.historyManager) {
void this.historyManager
.closeSession(event.sessionId, event.payload.code)
.catch((err) => console.warn('[history] closeSession failed:', event.sessionId, err))
}
this.initialCwds.delete(event.sessionId)
this.wslDistrosBySessionId.delete(event.sessionId)
this.sessionIncarnations.delete(event.sessionId)
this.clearExitedSessionState(
event.sessionId,
event.payload.code,
event.payload.incarnationId
)
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({
@@ -0,0 +1,87 @@
import { DaemonProtocolError } from './daemon-errors'
import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
import { isValidPtySize } from './daemon-pty-size'
import type { ListSessionsResult } from './types'
export type DaemonAppliedPtySize = { cols: number; rows: number }
type DaemonSizeClient = {
request<T = unknown>(type: string, payload: unknown): Promise<T>
}
type ReadDaemonAppliedPtySizeOptions = {
client: DaemonSizeClient
protocolVersion: number
sessionId: string
failureMode: 'preserve' | 'suppress'
getSizeUnsupported: boolean
markGetSizeUnsupported: () => void
}
/** Reads applied dimensions while preserving an attach caller's transport errors. */
export async function readDaemonAppliedPtySize(
options: ReadDaemonAppliedPtySizeOptions
): Promise<DaemonAppliedPtySize | null> {
const {
client,
protocolVersion,
sessionId,
failureMode,
getSizeUnsupported,
markGetSizeUnsupported
} = options
const readInventory = async (): Promise<DaemonAppliedPtySize | null> => {
const { sessions } = await client.request<ListSessionsResult>('listSessions', undefined)
const session = sessions.find((candidate) => candidate.sessionId === sessionId)
if (!session || !session.isAlive) {
return null
}
if (!isValidPtySize(session.cols, session.rows)) {
throw new DaemonProtocolError('Invalid listSessions size response')
}
return { cols: session.cols, rows: session.rows }
}
const useInventory = protocolVersion < GET_SIZE_PROTOCOL_VERSION || getSizeUnsupported
if (useInventory) {
try {
return await readInventory()
} catch (error) {
if (failureMode === 'preserve') {
throw error
}
return null
}
}
try {
const result = await client.request<{
size: { cols: number; rows: number } | null
}>('getSize', { sessionId })
if (result.size === null) {
return null
}
if (!isValidPtySize(result.size.cols, result.size.rows)) {
throw new DaemonProtocolError('Invalid getSize response')
}
return result.size
} catch (error) {
if (isUnknownRequestTypeError(error)) {
// `getSize` shipped without a protocol bump; cache the negative capability.
markGetSizeUnsupported()
try {
return await readInventory()
} catch (inventoryError) {
if (failureMode === 'preserve') {
throw inventoryError
}
return null
}
}
if (failureMode === 'preserve') {
throw error
}
return null
}
}
@@ -47,6 +47,11 @@ export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscri
if (previous && sameEndpointIdentity(previous, current)) {
return
}
if (previous) {
// Capability probes belong to one daemon incarnation; a replacement may
// support getSize even when the preserved owner did not.
this.getSizeUnsupported = false
}
this.lastAuthenticatedIdentity = { ...current }
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
if (!previous) {
+39 -1
View File
@@ -33,7 +33,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
export type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
exitsBySessionId: Map<string, { code: number; incarnationId?: string }[]>
ignoredExitIncarnationIds: Set<string>
ignoreNextExit: boolean
}
@@ -161,6 +161,44 @@ export abstract class DaemonPtyRuntimeState {
additionalEvidenceSources?: readonly DaemonEvidenceSource[],
endpointGoneProof?: 'windows_named_pipe_missing'
): void
protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void
protected abstract stopCheckpointTimerIfIdle(): void
protected clearExitedSessionState(
sessionId: string,
exitCode: number,
expectedIncarnationId?: string
): void {
const currentIncarnationId = this.sessionIncarnations.get(sessionId)
if (currentIncarnationId !== undefined && expectedIncarnationId !== currentIncarnationId) {
return
}
this.activeSessionIds.delete(sessionId)
this.clearSessionAwaitingDaemonRecovery(sessionId)
this.dirtySessionVersions.delete(sessionId)
this.pausedProducerSessionIds.delete(sessionId)
this.producerResumesOwedOnReconnect.delete(sessionId)
this.backgroundedSessionIds.delete(sessionId)
if (!this.sleepRestoreSessionIds.has(sessionId)) {
this.coldRestoreCache.delete(sessionId)
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
this.periodicDeadlineWarnedSessionIds.delete(sessionId)
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
this.stopCheckpointTimerIfIdle()
if (this.historyManager) {
void this.historyManager
.closeSession(sessionId, exitCode)
.catch((error) => console.warn('[history] closeSession failed:', sessionId, error))
}
this.initialCwds.delete(sessionId)
this.wslDistrosBySessionId.delete(sessionId)
this.sessionIncarnations.delete(sessionId)
}
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
+26 -26
View File
@@ -1,13 +1,13 @@
import type { ColdRestorePayload } from './cold-restore-payload-cache'
import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
import { readDaemonAppliedPtySize, type DaemonAppliedPtySize } from './daemon-pty-applied-size'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import type { ColdRestoreInfo } from './history-reader'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { SessionNotFoundError, type CreateOrAttachResult, type ListSessionsResult } from './types'
import { SessionNotFoundError, type ListSessionsResult } from './types'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import type { PtySpawnResult } from '../providers/types'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
@@ -25,31 +25,23 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn {
// Why size-first: attach must ride the session's own geometry — a fixed
// 80×24 here could resize a live agent's TUI — and a null size means the
// daemon cannot prove the session, so refuse rather than risk a create.
const size = await this.getAppliedSize(id)
// Keep transport failures distinct from an answered "absent". Mapping a
// dropped SSH/daemon connection to SessionNotFound would authorize a
// duplicate shell or retire a live persisted owner.
const size = await this.readAppliedSize(id, 'preserve')
if (!size) {
throw new SessionNotFoundError(id)
}
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
const result = await this.spawn({
sessionId: id,
cols: size.cols,
rows: size.rows,
attachOnly: true
})
if (result.isNew) {
// Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn
// instead of publishing a fresh shell as an attach.
await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => {
// Why surface, not swallow: a failed retire leaves an untracked orphan shell.
console.warn('[daemon] attach-only retire of accidental legacy spawn failed', {
sessionId: id,
error
})
})
if (result.exitedBeforeSpawnReply) {
throw new SessionNotFoundError(id)
}
this.clearSessionAwaitingDaemonRecovery(id)
const providerSequence = providerSequenceFromCreateOrAttach(result)
return providerSequence ? { providerSequence } : undefined
return result.providerSequence ? { providerSequence: result.providerSequence } : undefined
}
hasPty(id: string): boolean {
@@ -344,14 +336,22 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn {
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
)
return result.size ?? null
} catch {
return null
}
return await this.readAppliedSize(id, 'suppress')
}
private async readAppliedSize(
id: string,
failureMode: 'preserve' | 'suppress'
): Promise<DaemonAppliedPtySize | null> {
return await readDaemonAppliedPtySize({
client: this.client,
protocolVersion: this.protocolVersion,
sessionId: id,
failureMode,
getSizeUnsupported: this.getSizeUnsupported,
markGetSizeUnsupported: () => {
this.getSizeUnsupported = true
}
})
}
}
+15 -7
View File
@@ -26,8 +26,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
const operation: PendingDaemonSpawnOperation = {
exitsBySessionId: new Map(),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false
}
@@ -254,17 +254,25 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null {
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some(
const knownIncarnationId = this.sessionIncarnations.get(sessionId)
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).find(
(exit) =>
!(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
((exit.incarnationId === undefined &&
result.incarnationId === undefined &&
knownIncarnationId === undefined) ||
(exit.incarnationId !== undefined &&
result.incarnationId !== undefined &&
exit.incarnationId === result.incarnationId))
)
if (!matchingExit) {
return null
}
// Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state.
if (result.incarnationId) {
this.sessionIncarnations.set(sessionId, result.incarnationId)
}
this.clearExitedSessionState(sessionId, matchingExit.code, result.incarnationId)
// Why: stream exit can beat the control reply or post-reply recovery work; return proof without republishing dead state.
const exitedResult: PtySpawnResult = {
id: sessionId,
exitedBeforeSpawnReply: true,
@@ -56,7 +56,6 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
protected abstract setupEventRouting(): void
protected abstract scheduleCheckpointTimer(): void
protected abstract stopCheckpointTimer(): void
protected abstract stopCheckpointTimerIfIdle(): void
protected abstract recordAuthenticatedIdentity(): void
protected abstract runExclusiveCheckpoint(
operation: () => Promise<void>,
@@ -68,7 +67,6 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
sessionId: string,
operation: () => Promise<T>
): Promise<T>
protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void
protected abstract reconnectAfterWriteFailure(): void
protected abstract checkpointSessions(
sessionIds: Iterable<string>,
+21 -17
View File
@@ -1,5 +1,6 @@
import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { retireUnexpectedAttachOnlySpawn } from './daemon-attach-only-retirement'
import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze'
@@ -17,9 +18,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
operation,
historyRecovery,
requestedSessionId,
emulateLegacyAttachOnly,
restoreSkippedForLiveSession,
detectColdRestore
attachOnly,
restoreSkippedForLiveSession
} = context
let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context
const createOrAttach = (historySeedSegments: readonly string[] | null) => {
@@ -32,6 +32,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
return this.createOrAttachSpawn(context, historySeedSegments)
}
let result = initialResult
const finalizeSpawnResult = (spawnResult: PtySpawnResult): PtySpawnResult =>
this.resultForExitBeforeSpawnReply(sessionId, result, operation) ?? spawnResult
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
const requestedSessionId = sessionId
@@ -58,9 +60,11 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
restoreInfo = null
historySeedSegments = null
}
if (emulateLegacyAttachOnly && result.isNew) {
if (attachOnly && result.isNew) {
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId: requestedSessionId, immediate: true })
await retireUnexpectedAttachOnlySpawn(requestedSessionId, () =>
this.client.request('kill', { sessionId: requestedSessionId, immediate: true })
)
throw new SessionNotFoundError(requestedSessionId)
}
await adoptSpawnResultSession(result)
@@ -110,7 +114,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
this.historyManager.reopenSession(sessionId, recoveryFreeze)
}
}
return {
return finalizeSpawnResult({
id: sessionId,
...incarnationResult(),
pid,
@@ -119,13 +123,13 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
coldRestore: cachedRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
})
}
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
restoreInfo = await context.detectColdRestore({ ignoreCleanEnd: true })
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
// Why: the aliveness probe raced with session death, so the first
@@ -163,7 +167,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
!result.isNew &&
result.historySeeded === false
) {
restoreInfo = await detectColdRestore()
restoreInfo = await context.detectColdRestore()
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
}
@@ -204,7 +208,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
}
if (coldRestore) {
this.coldRestoreCache.set(sessionId, coldRestore)
return {
return finalizeSpawnResult({
id: sessionId,
...incarnationResult(),
pid,
@@ -214,9 +218,9 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
})
}
return {
return finalizeSpawnResult({
id: sessionId,
...incarnationResult(),
pid,
@@ -224,7 +228,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {})
}
})
}
if (this.historyManager && !historyRecovery.identityChanged && result.isNew) {
@@ -264,7 +268,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
const isReattach = !result.isNew
if (!isReattach || !result.snapshot) {
return {
return finalizeSpawnResult({
id: sessionId,
...incarnationResult(),
pid,
@@ -273,7 +277,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
})
}
const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot)
@@ -291,7 +295,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
reattachSnapshot.modes.kittyKeyboardFlags
)
return {
return finalizeSpawnResult({
id: sessionId,
...incarnationResult(),
pid,
@@ -324,6 +328,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
...(reattachSnapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi }
: {})
}
})
}
}
@@ -0,0 +1,155 @@
import type { Socket } from 'node:net'
import { spawn, type IPty } from 'node-pty'
import { describe, expect, it } from 'vitest'
type WindowsPtyInternals = IPty & {
_agent: { inSocket: Socket }
_socket: Socket
}
function waitForOutput(terminal: IPty, marker: string): Promise<void> {
return new Promise((resolve, reject) => {
let output = ''
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${marker}; got ${output}`)),
10_000
)
terminal.onData((chunk) => {
output += chunk
if (output.includes(marker)) {
clearTimeout(timeout)
resolve()
}
})
})
}
function waitForExit(terminal: IPty): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for the failed PTY to exit')),
10_000
)
terminal.onExit(() => {
clearTimeout(timeout)
resolve()
})
})
}
describe.skipIf(process.platform !== 'win32')('node-pty Windows input errors', () => {
it('retires only the failed PTY and keeps a witness writable after ConPTY EAGAIN', async () => {
const uncaught: unknown[] = []
const uncaughtListener = (error: unknown): void => {
uncaught.push(error)
}
process.on('uncaughtException', uncaughtListener)
let terminal: IPty | undefined
let witness: IPty | undefined
try {
const options = {
cwd: process.cwd(),
env: process.env,
useConptyDll: false
}
terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], options)
witness = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], options)
const input = (terminal as WindowsPtyInternals)._agent.inSocket
expect(input.listenerCount('error')).toBeGreaterThan(0)
expect(() =>
input.emit('error', Object.assign(new Error('write EAGAIN'), { code: 'EAGAIN' }))
).not.toThrow()
await waitForExit(terminal)
await new Promise((resolve) => setTimeout(resolve, 1_500))
witness.write('echo ORCA_CONPTY_WITNESS\r')
await waitForOutput(witness, 'ORCA_CONPTY_WITNESS')
expect(uncaught).toEqual([])
} finally {
try {
terminal?.kill()
} catch {}
try {
witness?.kill()
} catch {}
// ConPTY's worker drains asynchronously; keep the guard installed through
// the delayed close so cleanup cannot reintroduce an unhandled error.
await new Promise((resolve) => setTimeout(resolve, 1_500))
process.off('uncaughtException', uncaughtListener)
}
}, 20_000)
it('ignores a late output EPIPE after the PTY has closed', async () => {
const uncaught: unknown[] = []
const uncaughtListener = (error: unknown): void => {
uncaught.push(error)
}
process.on('uncaughtException', uncaughtListener)
let terminal: IPty | undefined
let exited = false
try {
terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], {
cwd: process.cwd(),
env: process.env,
useConptyDll: false
})
const output = (terminal as WindowsPtyInternals)._socket
const exit = waitForExit(terminal).then(() => {
exited = true
})
terminal.kill()
await exit
expect(() => {
output.emit(
'error',
Object.assign(new Error('This socket has been ended by the other party'), {
code: 'EPIPE'
})
)
}).not.toThrow()
expect(uncaught).toEqual([])
} finally {
if (!exited) {
try {
terminal?.kill()
} catch {}
}
await new Promise((resolve) => setTimeout(resolve, 1_500))
process.off('uncaughtException', uncaughtListener)
}
}, 20_000)
it('contains an output EPIPE that races with PTY shutdown', async () => {
const uncaught: unknown[] = []
const uncaughtListener = (error: unknown): void => {
uncaught.push(error)
}
process.on('uncaughtException', uncaughtListener)
let terminal: IPty | undefined
try {
terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], {
cwd: process.cwd(),
env: process.env,
useConptyDll: false
})
const output = (terminal as WindowsPtyInternals)._socket
const exit = waitForExit(terminal)
expect(() => {
output.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
terminal?.kill()
}).not.toThrow()
await exit
expect(uncaught).toEqual([])
} finally {
try {
terminal?.kill()
} catch {}
await new Promise((resolve) => setTimeout(resolve, 1_500))
process.off('uncaughtException', uncaughtListener)
}
}, 20_000)
})
+1
View File
@@ -102,6 +102,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(ctx.preAllocatedHandle ? { terminalHandle: ctx.preAllocatedHandle } : {}),
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(agentLaunchAuthority ? { agentLaunchAuthority } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors'
import {
SSH_SESSION_EXPIRED_ERROR,
SshPtyAbsentFromRelayError
@@ -161,5 +162,19 @@ describe('stable pane adoption after the relay reports the PTY absent', () => {
expect(spawn).toHaveBeenCalledTimes(1)
expect(JSON.stringify(read())).toBe(before)
})
it('does not retire the binding when daemon attach-only cleanup is unverifiable', async () => {
const { store, read } = sessionStore([LEAF, SIBLING_LEAF])
const before = JSON.stringify(read())
const { run, spawn } = spawnAfterAttachRejection(
new TerminalSessionOwnerUnverifiedError(OWNER.ptyId),
{ store, worktreeId: WORKTREE }
)
await expect(run()).rejects.toThrow('terminal_pane_owner_unverified')
expect(spawn).toHaveBeenCalledTimes(1)
expect(JSON.stringify(read())).toBe(before)
})
})
})
+3 -2
View File
@@ -71,6 +71,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
{
tabId: owner.surface.tabId,
leafId: owner.surface.leafId,
terminalHandle: owner.surface.terminalHandle,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
@@ -113,7 +114,6 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
) {
markNativeWindowsConptyPty(ctx.result.id)
}
const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id)
const persistSshLease = (): void => {
if (!ctx.deps.store || !args.connectionId) {
return
@@ -121,7 +121,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
// Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration.
ctx.deps.store.upsertSshRemotePtyLease({
targetId: args.connectionId,
ptyId: relayResultId,
ptyId: getRelayPtyId(args.connectionId, ctx.result.id),
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId)
@@ -207,6 +207,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(args.preAllocatedHandle ? { terminalHandle: args.preAllocatedHandle } : {}),
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
+87 -1
View File
@@ -10,7 +10,10 @@ import {
import {
REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES,
_getRemoteWorkspaceSnapshotForTests,
_rememberRemoteWorkspaceSnapshotForTests
_rememberRemoteWorkspaceSnapshotForTests,
cachedRemoteWorkspaceSnapshotAuthorizesRevision,
rememberLocallyPatchedRemoteWorkspaceSnapshot,
rememberRemoteWorkspaceSnapshot
} from './remote-workspace-snapshot-cache'
function emptyRemoteWorkspaceSession(): RemoteWorkspaceSession {
@@ -54,6 +57,7 @@ describe('remote workspace snapshot cache', () => {
REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES
)
expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined()
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-0', 7)).toBe(false)
expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe(
'/repo-1'
)
@@ -73,4 +77,86 @@ describe('remote workspace snapshot cache', () => {
expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined()
expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined()
})
it('keeps contiguous local patch bases authorized until the host changes', () => {
rememberRemoteWorkspaceSnapshot('target-1', snapshot(emptyRemoteWorkspaceSession(), 7))
rememberLocallyPatchedRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 8)
)
rememberLocallyPatchedRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 9)
)
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 7)).toBe(true)
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 8)).toBe(true)
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 9)).toBe(true)
rememberRemoteWorkspaceSnapshot('target-1', snapshot(emptyRemoteWorkspaceSession(), 10))
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 9)).toBe(false)
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 10)).toBe(true)
})
it('keeps the observation token stable when an unchanged revision is re-read', () => {
const first = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 7)
)
const second = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 7)
)
expect(second.hostObservationToken).toBe(first.hostObservationToken)
expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 7)).toBe(true)
})
it('rotates the observation token when same-revision content changes', () => {
const first = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 7)
)
const second = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(
{
...emptyRemoteWorkspaceSession(),
activeTabId: 'changed'
},
7
)
)
expect(second.hostObservationToken).not.toBe(first.hostObservationToken)
})
it('keeps local patch authority across equivalent normalized relay reads', () => {
const base = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 7)
)
const locallyPatched = rememberLocallyPatchedRemoteWorkspaceSnapshot(
'target-1',
snapshot(
{
...emptyRemoteWorkspaceSession(),
activeWorktreePathsOnShutdown: [],
activeTabIdByWorktreePath: {},
remoteSessionIdsByTabId: {},
lastVisitedAtByWorktreePath: {},
defaultTerminalTabsAppliedByWorktreePath: {}
},
8
)
)
const relayRead = rememberRemoteWorkspaceSnapshot(
'target-1',
snapshot(emptyRemoteWorkspaceSession(), 8)
)
expect(locallyPatched.hostObservationToken).toBe(base.hostObservationToken)
expect(relayRead.hostObservationToken).toBe(locallyPatched.hostObservationToken)
})
})
@@ -36,8 +36,16 @@ vi.mock('./remote-workspace-events', () => ({
import {
_resetRemoteWorkspaceCachesForTests,
handleRemoteWorkspaceNotification,
registerRemoteWorkspaceHandlers
} from './remote-workspace'
import { CLIENT_ID } from './remote-workspace-client-identity'
import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue'
import {
REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES,
getCachedRemoteWorkspaceSnapshot,
rememberRemoteWorkspaceSnapshot
} from './remote-workspace-snapshot-cache'
function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot {
return {
@@ -92,7 +100,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
vi.mocked(ipcMain.removeHandler).mockReset()
getSshConnectionStoreMock.mockReset()
getSshConnectionStoreMock.mockReturnValue({
listTargets: () => [target]
listTargets: () => [target],
getTarget: (targetId: string) => (targetId === target.id ? target : undefined)
})
getRepoMock.mockReset()
getRepoMock.mockImplementation((repoId: string) =>
@@ -117,6 +126,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
async function callSetForConnectedTargets(args: {
session: WorkspaceSessionState
hydratedTargetIds?: unknown
expectedRevisionsByTargetId?: unknown
expectedHostObservationTokensByTargetId?: unknown
}): Promise<unknown> {
const handler = handlers.get('remoteWorkspace:setForConnectedTargets')
if (!handler) {
@@ -125,6 +136,18 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
return handler(null, args)
}
function observeSnapshot(targetId: string, value: RemoteWorkspaceSnapshot): string {
return rememberRemoteWorkspaceSnapshot(targetId, value).hostObservationToken
}
function cachedObservationToken(targetId: string): string {
const cached = getCachedRemoteWorkspaceSnapshot(targetId)
if (!cached) {
throw new Error(`No cached workspace observation for ${targetId}`)
}
return cached.hostObservationToken
}
it('serializes overlapping writes for the same target so they use fresh base revisions', async () => {
let currentRevision = 7
let releaseFirstPatch!: () => void
@@ -152,36 +175,313 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
await firstPatchCanFinish
}
currentRevision += 1
const patchedSnapshot = snapshot(patchSession(params), currentRevision)
handleRemoteWorkspaceNotification('target-1', 'workspace.changed', {
snapshot: patchedSnapshot,
sourceClientId: CLIENT_ID
})
return {
ok: true,
snapshot: snapshot(patchSession(params), currentRevision)
snapshot: patchedSnapshot
}
}
throw new Error(`Unexpected method ${method}`)
})
muxByTargetId.set('target-1', { request })
const observationToken = observeSnapshot(
'target-1',
snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
)
const first = callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'),
hydratedTargetIds: ['target-1']
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7]))
const second = callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'),
hydratedTargetIds: ['target-1']
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(patchBaseRevisions).toEqual([7])
releaseFirstPatch()
await expect(Promise.all([first, second])).resolves.toMatchObject([
[{ targetId: 'target-1', result: { ok: true } }],
[{ targetId: 'target-1', result: { ok: true } }]
[
{
targetId: 'target-1',
result: {
ok: true,
snapshot: { revision: 8, hostObservationToken: observationToken }
}
}
],
[
{
targetId: 'target-1',
result: {
ok: true,
snapshot: { revision: 9, hostObservationToken: observationToken }
}
}
]
])
expect(patchBaseRevisions).toEqual([7, 8])
})
it('rejects token A after a different same-revision host observation arrives before admission', async () => {
const remoteSnapshot = snapshot(
{
activeWorktreePath: '/other-device',
activeTabId: 'host-tab',
tabsByWorktreePath: {
'/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never]
},
terminalLayoutsByTabId: {}
},
7
)
const request = vi.fn()
muxByTargetId.set('target-1', { request })
const observationToken = observeSnapshot(
'target-1',
snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
)
handleRemoteWorkspaceNotification('target-1', 'workspace.changed', {
snapshot: remoteSnapshot,
sourceClientId: 'other-client'
})
const result = await callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'),
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
expect(result).toMatchObject([
{
targetId: 'target-1',
result: {
ok: false,
reason: 'stale-revision',
snapshot: { revision: 7 }
}
}
])
expect(request).not.toHaveBeenCalled()
})
it('rejects a renderer upload when a host snapshot arrives while it is queued', async () => {
const remoteSnapshot = snapshot(
{
activeWorktreePath: '/other-device',
activeTabId: 'host-tab',
tabsByWorktreePath: {
'/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never]
},
terminalLayoutsByTabId: {}
},
8
)
let releasePatch!: () => void
const patchCanFinish = new Promise<void>((resolve) => {
releasePatch = resolve
})
const request = vi.fn(async (method: string) => {
if (method === 'workspace.get') {
return snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
}
if (method === 'workspace.patch') {
await patchCanFinish
return { ok: false, reason: 'stale-revision', snapshot: remoteSnapshot }
}
throw new Error(`Unexpected method ${method}`)
})
muxByTargetId.set('target-1', { request })
const observationToken = observeSnapshot(
'target-1',
snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
)
const first = callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/first', 'first-local-tab'),
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
await vi.waitFor(() =>
expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1)
)
const queued = callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab'),
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
await new Promise((resolve) => setTimeout(resolve, 0))
handleRemoteWorkspaceNotification('target-1', 'workspace.changed', {
snapshot: remoteSnapshot,
sourceClientId: 'other-client'
})
releasePatch()
await expect(Promise.all([first, queued])).resolves.toMatchObject([
[{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }],
[{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }]
])
expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1)
})
it('rejects a queued upload after a same-revision host observation replaces its lineage', async () => {
const baseline = snapshot(
{
activeWorktreePath: '/baseline',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
const replacement = snapshot(
{
activeWorktreePath: '/other-device',
activeTabId: 'host-tab',
tabsByWorktreePath: {
'/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never]
},
terminalLayoutsByTabId: {}
},
7
)
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method !== 'workspace.patch') {
throw new Error(`Unexpected method ${method}`)
}
return { ok: true, snapshot: snapshot(patchSession(params), 8) }
})
muxByTargetId.set('target-1', { request })
handleRemoteWorkspaceNotification('target-1', 'workspace.changed', {
snapshot: baseline,
sourceClientId: CLIENT_ID
})
const observationToken = cachedObservationToken('target-1')
let releaseBlocker!: () => void
const blockerCanFinish = new Promise<void>((resolve) => {
releaseBlocker = resolve
})
let blockerStarted!: () => void
const blockerDidStart = new Promise<void>((resolve) => {
blockerStarted = resolve
})
const blocker = queueRemoteWorkspacePatch('target-1', async () => {
blockerStarted()
await blockerCanFinish
})
await blockerDidStart
const queued = callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'),
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
await new Promise((resolve) => setTimeout(resolve, 0))
handleRemoteWorkspaceNotification('target-1', 'workspace.changed', {
snapshot: replacement,
sourceClientId: 'other-client'
})
releaseBlocker()
await blocker
await expect(queued).resolves.toMatchObject([
{
targetId: 'target-1',
result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } }
}
])
expect(request).not.toHaveBeenCalled()
})
it('fails closed after token A is evicted even when the fetched revision still matches', async () => {
const baseline = snapshot(
{
activeWorktreePath: '/baseline',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
const observationToken = observeSnapshot('target-1', baseline)
for (let index = 0; index < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; index += 1) {
observeSnapshot(`eviction-target-${index}`, baseline)
}
expect(getCachedRemoteWorkspaceSnapshot('target-1')).toBeUndefined()
const request = vi.fn(async (method: string) => {
if (method === 'workspace.get') {
return baseline
}
throw new Error(`Unexpected method ${method}`)
})
muxByTargetId.set('target-1', { request })
await expect(
callSetForConnectedTargets({
session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'),
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: { 'target-1': observationToken }
})
).resolves.toMatchObject([
{
targetId: 'target-1',
result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } }
}
])
expect(request.mock.calls.map(([method]) => method)).toEqual(['workspace.get'])
})
it('patches independent hydrated targets concurrently', async () => {
const secondTarget: SshTarget = {
id: 'target-2',
@@ -251,6 +551,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
})
muxByTargetId.set('target-1', { request: slowRequest })
muxByTargetId.set('target-2', { request: fastRequest })
const firstObservationToken = observeSnapshot('target-1', previousSnapshot)
const secondObservationToken = observeSnapshot('target-2', previousSnapshot)
const resultPromise = callSetForConnectedTargets({
session: {
@@ -274,7 +576,12 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
]
}
},
hydratedTargetIds: ['target-1', 'target-2']
hydratedTargetIds: ['target-1', 'target-2'],
expectedRevisionsByTargetId: { 'target-1': 7, 'target-2': 7 },
expectedHostObservationTokensByTargetId: {
'target-1': firstObservationToken,
'target-2': secondObservationToken
}
})
await vi.waitFor(() =>
@@ -355,11 +662,25 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
throw new Error(`Unexpected method ${method}`)
})
muxByTargetId.set('target-reset', { request })
const observationToken = observeSnapshot(
'target-reset',
snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
)
await expect(
callSetForConnectedTargets({
session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'),
hydratedTargetIds: ['target-reset']
hydratedTargetIds: ['target-reset'],
expectedRevisionsByTargetId: { 'target-reset': 7 },
expectedHostObservationTokensByTargetId: { 'target-reset': observationToken }
})
).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }])
expect(patchBaseRevisions).toEqual([7, 0])
@@ -423,11 +744,25 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => {
throw new Error(`Unexpected method ${method}`)
})
muxByTargetId.set('target-newer', { request })
const observationToken = observeSnapshot(
'target-newer',
snapshot(
{
activeWorktreePath: '/previous',
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
},
7
)
)
await expect(
callSetForConnectedTargets({
session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'),
hydratedTargetIds: ['target-newer']
hydratedTargetIds: ['target-newer'],
expectedRevisionsByTargetId: { 'target-newer': 7 },
expectedHostObservationTokensByTargetId: { 'target-newer': observationToken }
})
).resolves.toMatchObject([
{ targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } }
+32 -18
View File
@@ -1,7 +1,8 @@
import type {
RemoteWorkspaceObservedPatchResult,
RemoteWorkspaceObservedSnapshot,
RemoteWorkspacePatchResult,
RemoteWorkspaceSession,
RemoteWorkspaceSnapshot
RemoteWorkspaceSession
} from '../../shared/remote-workspace-types'
import type { SshTarget } from '../../shared/ssh-types'
import { getActiveMultiplexer } from './ssh'
@@ -9,6 +10,7 @@ import { CLIENT_ID } from './remote-workspace-client-identity'
import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace'
import {
getCachedRemoteWorkspaceSnapshot,
rememberLocallyPatchedRemoteWorkspaceSnapshot,
rememberRemoteWorkspaceSnapshot
} from './remote-workspace-snapshot-cache'
import {
@@ -18,7 +20,7 @@ import {
export async function getRemoteSnapshot(
target: SshTarget
): Promise<RemoteWorkspaceSnapshot | null> {
): Promise<RemoteWorkspaceObservedSnapshot | null> {
const mux = getActiveMultiplexer(target.id)
if (!mux) {
return null
@@ -27,8 +29,7 @@ export async function getRemoteSnapshot(
try {
const raw = await mux.request('workspace.get', { namespace })
const snapshot = normalizeSnapshot(raw, namespace)
rememberRemoteWorkspaceSnapshot(target.id, snapshot)
return snapshot
return rememberRemoteWorkspaceSnapshot(target.id, snapshot)
} catch (err) {
if ((err as { code?: unknown })?.code === -32601) {
return null
@@ -37,10 +38,33 @@ export async function getRemoteSnapshot(
}
}
function observePatchResult(
targetId: string,
result: RemoteWorkspacePatchResult
): RemoteWorkspaceObservedPatchResult {
if (result.ok) {
return {
ok: true,
snapshot: rememberLocallyPatchedRemoteWorkspaceSnapshot(targetId, result.snapshot)
}
}
const failure = {
ok: false as const,
reason: result.reason,
...(result.message !== undefined ? { message: result.message } : {})
}
return result.snapshot
? {
...failure,
snapshot: rememberRemoteWorkspaceSnapshot(targetId, result.snapshot)
}
: failure
}
export async function patchRemoteWorkspaceSession(
target: SshTarget,
session: RemoteWorkspaceSession
): Promise<RemoteWorkspacePatchResult | null> {
): Promise<RemoteWorkspaceObservedPatchResult | null> {
const mux = getActiveMultiplexer(target.id)
if (!mux) {
return null
@@ -80,14 +104,10 @@ export async function patchRemoteWorkspaceSession(
}
}
const result = await requestPatch(current?.revision)
const result = observePatchResult(target.id, await requestPatch(current?.revision))
if (result.ok) {
rememberRemoteWorkspaceSnapshot(target.id, result.snapshot)
return result
}
if (result.snapshot) {
rememberRemoteWorkspaceSnapshot(target.id, result.snapshot)
}
if (
result.reason === 'stale-revision' &&
@@ -102,13 +122,7 @@ export async function patchRemoteWorkspaceSession(
// backwards while this process still has the old cached revision. Retrying
// only for backwards revisions restores the blank-slate target without
// overwriting a newer snapshot from another device.
const retry = await requestPatch(result.snapshot.revision)
if (retry.ok) {
rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot)
} else if (retry.snapshot) {
rememberRemoteWorkspaceSnapshot(target.id, retry.snapshot)
}
return retry
return observePatchResult(target.id, await requestPatch(result.snapshot.revision))
}
return result
+109 -11
View File
@@ -1,17 +1,43 @@
import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types'
import { randomUUID } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import type {
RemoteWorkspaceObservedSnapshot,
RemoteWorkspaceSnapshot
} from '../../shared/remote-workspace-types'
import { normalizeSnapshot } from './remote-workspace-snapshot-normalization'
export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64
const latestSnapshotByTargetId = new Map<string, RemoteWorkspaceSnapshot>()
type RemoteWorkspaceSnapshotCacheEntry = {
snapshot: RemoteWorkspaceObservedSnapshot
// Why: overlapping renderer writes retain their applied base until earlier same-client patches acknowledge.
minimumAuthorizedRevision: number
maximumAuthorizedRevision: number
}
export function rememberRemoteWorkspaceSnapshot(
const latestSnapshotByTargetId = new Map<string, RemoteWorkspaceSnapshotCacheEntry>()
function snapshotsAreIdentical(
previous: RemoteWorkspaceObservedSnapshot,
next: RemoteWorkspaceSnapshot
): boolean {
return (
previous.namespace === next.namespace &&
previous.revision === next.revision &&
previous.updatedAt === next.updatedAt &&
previous.schemaVersion === next.schemaVersion &&
isDeepStrictEqual(previous.session, next.session)
)
}
function rememberRemoteWorkspaceSnapshotEntry(
targetId: string,
snapshot: RemoteWorkspaceSnapshot
entry: RemoteWorkspaceSnapshotCacheEntry
): void {
if (latestSnapshotByTargetId.has(targetId)) {
latestSnapshotByTargetId.delete(targetId)
}
latestSnapshotByTargetId.set(targetId, snapshot)
latestSnapshotByTargetId.set(targetId, entry)
while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) {
const oldest = latestSnapshotByTargetId.keys().next()
if (oldest.done) {
@@ -21,17 +47,89 @@ export function rememberRemoteWorkspaceSnapshot(
}
}
export function rememberRemoteWorkspaceSnapshot(
targetId: string,
snapshot: RemoteWorkspaceSnapshot
): RemoteWorkspaceObservedSnapshot {
// Relay responses can carry legacy empty optional fields that normalization
// removes on reads. Keep one canonical shape in the cache so equivalent
// observations do not revoke an in-flight upload authority.
const normalizedSnapshot = normalizeSnapshot(snapshot, snapshot.namespace)
const current = latestSnapshotByTargetId.get(targetId)
if (current && snapshotsAreIdentical(current.snapshot, normalizedSnapshot)) {
// Re-reading an unchanged revision is not a new host observation. Keep the
// token (and the contiguous local-patch authorization window) stable so a
// polling read cannot invalidate an upload that is already in flight.
const observedSnapshot = {
...normalizedSnapshot,
hostObservationToken: current.snapshot.hostObservationToken
}
rememberRemoteWorkspaceSnapshotEntry(targetId, {
...current,
snapshot: observedSnapshot
})
return observedSnapshot
}
const observedSnapshot = { ...normalizedSnapshot, hostObservationToken: randomUUID() }
rememberRemoteWorkspaceSnapshotEntry(targetId, {
snapshot: observedSnapshot,
minimumAuthorizedRevision: normalizedSnapshot.revision,
maximumAuthorizedRevision: normalizedSnapshot.revision
})
return observedSnapshot
}
export function rememberLocallyPatchedRemoteWorkspaceSnapshot(
targetId: string,
snapshot: RemoteWorkspaceSnapshot
): RemoteWorkspaceObservedSnapshot {
const normalizedSnapshot = normalizeSnapshot(snapshot, snapshot.namespace)
const current = latestSnapshotByTargetId.get(targetId)
if (!current || normalizedSnapshot.revision > current.maximumAuthorizedRevision + 1) {
return rememberRemoteWorkspaceSnapshot(targetId, normalizedSnapshot)
}
if (normalizedSnapshot.revision < current.snapshot.revision) {
rememberRemoteWorkspaceSnapshotEntry(targetId, current)
return current.snapshot
}
const observedSnapshot = {
...normalizedSnapshot,
hostObservationToken: current.snapshot.hostObservationToken
}
rememberRemoteWorkspaceSnapshotEntry(targetId, {
snapshot: observedSnapshot,
minimumAuthorizedRevision: current.minimumAuthorizedRevision,
maximumAuthorizedRevision: Math.max(
current.maximumAuthorizedRevision,
normalizedSnapshot.revision
)
})
return observedSnapshot
}
export function getCachedRemoteWorkspaceSnapshot(
targetId: string
): RemoteWorkspaceSnapshot | undefined {
const snapshot = latestSnapshotByTargetId.get(targetId)
if (!snapshot) {
): RemoteWorkspaceObservedSnapshot | undefined {
const entry = latestSnapshotByTargetId.get(targetId)
if (!entry) {
return undefined
}
// Why: remote workspace snapshots can contain the whole tab/layout session
// for a target. Touch cache hits so deleted or rarely used targets age out.
rememberRemoteWorkspaceSnapshot(targetId, snapshot)
return snapshot
rememberRemoteWorkspaceSnapshotEntry(targetId, entry)
return entry.snapshot
}
export function cachedRemoteWorkspaceSnapshotAuthorizesRevision(
targetId: string,
revision: number
): boolean {
const entry = latestSnapshotByTargetId.get(targetId)
return (
entry !== undefined &&
revision >= entry.minimumAuthorizedRevision &&
revision <= entry.maximumAuthorizedRevision
)
}
export function clearRemoteWorkspaceSnapshotCache(): void {
@@ -53,6 +151,6 @@ export function _rememberRemoteWorkspaceSnapshotForTests(
/** @internal - exposed for cache-bound tests only. */
export function _getRemoteWorkspaceSnapshotForTests(
targetId: string
): RemoteWorkspaceSnapshot | undefined {
): RemoteWorkspaceObservedSnapshot | undefined {
return getCachedRemoteWorkspaceSnapshot(targetId)
}
+59 -3
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
RemoteWorkspaceObservedSnapshot,
RemoteWorkspaceSession,
RemoteWorkspaceSnapshot
} from '../../shared/remote-workspace-types'
@@ -169,7 +170,8 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
vi.mocked(ipcMain.removeHandler).mockReset()
getSshConnectionStoreMock.mockReset()
getSshConnectionStoreMock.mockReturnValue({
listTargets: () => targets
listTargets: () => targets,
getTarget: (targetId: string) => targets.find((target) => target.id === targetId)
})
getRepoMock.mockReset()
getWorkspaceSessionMock.mockReset()
@@ -225,6 +227,8 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
async function callSetForConnectedTargets(args: {
session?: WorkspaceSessionState
hydratedTargetIds?: unknown
expectedRevisionsByTargetId?: unknown
expectedHostObservationTokensByTargetId?: unknown
}): Promise<unknown> {
const handler = handlers.get('remoteWorkspace:setForConnectedTargets')
if (!handler) {
@@ -233,6 +237,18 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
return handler(null, args)
}
async function observeTarget(targetId: string): Promise<RemoteWorkspaceObservedSnapshot> {
const handler = handlers.get('remoteWorkspace:get')
if (!handler) {
throw new Error('remoteWorkspace:get handler was never registered')
}
const observed = await handler(null, { targetId })
if (!observed || typeof observed !== 'object' || !('hostObservationToken' in observed)) {
throw new Error(`remoteWorkspace:get did not observe ${targetId}`)
}
return observed as RemoteWorkspaceObservedSnapshot
}
it('does not write without an explicit non-empty hydrated target set', async () => {
await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([])
await expect(
@@ -241,15 +257,31 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
await expect(
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] })
).resolves.toEqual([])
await expect(
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] })
).resolves.toEqual([])
await expect(
callSetForConnectedTargets({
session: baseSession,
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 }
})
).resolves.toEqual([])
expect(getSshConnectionStoreMock).not.toHaveBeenCalled()
expect(getActiveMultiplexerMock).not.toHaveBeenCalled()
})
it('writes only to explicitly hydrated connected targets', async () => {
const observation = await observeTarget('target-1')
const result = await callSetForConnectedTargets({
session: baseSession,
hydratedTargetIds: ['target-1', 'missing-target']
hydratedTargetIds: ['target-1', 'missing-target'],
expectedRevisionsByTargetId: { 'target-1': 7, 'missing-target': 7 },
expectedHostObservationTokensByTargetId: {
'target-1': observation.hostObservationToken,
'missing-target': 'unreachable-target-observation'
}
})
expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }])
@@ -282,7 +314,14 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
terminalLayoutsByTabId: {}
})
await callSetForConnectedTargets({ hydratedTargetIds: ['target-1'] })
const observation = await observeTarget('target-1')
await callSetForConnectedTargets({
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': 7 },
expectedHostObservationTokensByTargetId: {
'target-1': observation.hostObservationToken
}
})
expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith(
'workspace.patch',
@@ -296,4 +335,21 @@ describe('remoteWorkspace:setForConnectedTargets', () => {
})
)
})
it('does not invalidate an upload authority when an unchanged snapshot is polled', async () => {
const first = await observeTarget('target-1')
const second = await observeTarget('target-1')
expect(second.hostObservationToken).toBe(first.hostObservationToken)
const result = await callSetForConnectedTargets({
session: baseSession,
hydratedTargetIds: ['target-1'],
expectedRevisionsByTargetId: { 'target-1': first.revision },
expectedHostObservationTokensByTargetId: {
'target-1': first.hostObservationToken
}
})
expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }])
})
})
+92 -9
View File
@@ -4,7 +4,7 @@ import { getActiveMultiplexer, getSshConnectionStore } from './ssh'
import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection'
import type {
RemoteWorkspaceChangedEvent,
RemoteWorkspacePatchResult,
RemoteWorkspaceObservedPatchResult,
RemoteWorkspaceSession
} from '../../shared/remote-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
@@ -21,8 +21,11 @@ import {
} from './remote-workspace-patch-queue'
import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync'
import {
cachedRemoteWorkspaceSnapshotAuthorizesRevision,
clearRemoteWorkspaceSnapshotCache,
getCachedRemoteWorkspaceSnapshot,
getRemoteWorkspaceSnapshotCacheSize,
rememberLocallyPatchedRemoteWorkspaceSnapshot,
rememberRemoteWorkspaceSnapshot
} from './remote-workspace-snapshot-cache'
import { normalizeSnapshot } from './remote-workspace-snapshot-normalization'
@@ -56,6 +59,42 @@ function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
return new Set(value)
}
function getExpectedTargetRevisions(
value: unknown,
targetIds: ReadonlySet<string>
): Map<string, number> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const revisions = new Map<string, number>()
for (const targetId of targetIds) {
const revision = (value as Record<string, unknown>)[targetId]
if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) {
return null
}
revisions.set(targetId, revision)
}
return revisions
}
function getExpectedHostObservationTokens(
value: unknown,
targetIds: ReadonlySet<string>
): Map<string, string> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const tokens = new Map<string, string>()
for (const targetId of targetIds) {
const token = (value as Record<string, unknown>)[targetId]
if (typeof token !== 'string' || token.length === 0 || token.length > 128) {
return null
}
tokens.set(targetId, token)
}
return tokens
}
function targetForWorktree(
store: Store,
worktreeId: string,
@@ -94,11 +133,16 @@ export function handleRemoteWorkspaceNotification(
}
const namespace = getRemoteWorkspaceNamespace(target)
const snapshot = normalizeSnapshot(params.snapshot, namespace)
rememberRemoteWorkspaceSnapshot(targetId, snapshot)
const sourceClientId =
typeof params.sourceClientId === 'string' ? params.sourceClientId : undefined
const observedSnapshot =
sourceClientId === CLIENT_ID
? rememberLocallyPatchedRemoteWorkspaceSnapshot(targetId, snapshot)
: rememberRemoteWorkspaceSnapshot(targetId, snapshot)
const event: RemoteWorkspaceChangedEvent = {
targetId,
snapshot,
sourceClientId: typeof params.sourceClientId === 'string' ? params.sourceClientId : undefined
snapshot: observedSnapshot,
sourceClientId
}
const win = mainWindowGetter?.()
if (win && !win.isDestroyed()) {
@@ -131,13 +175,35 @@ export function registerRemoteWorkspaceHandlers(
ipcMain.handle(
'remoteWorkspace:setForConnectedTargets',
async (_event, args: { session?: WorkspaceSessionState; hydratedTargetIds?: unknown }) => {
async (
_event,
args: {
session?: WorkspaceSessionState
hydratedTargetIds?: unknown
expectedRevisionsByTargetId?: unknown
expectedHostObservationTokensByTargetId?: unknown
}
) => {
const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds)
if (!hydratedTargetIds) {
// Why: an omitted hydration set used to broadcast one session to every
// SSH target, overwriting unrelated remote workspace snapshots.
return []
}
const expectedRevisions = getExpectedTargetRevisions(
args.expectedRevisionsByTargetId,
hydratedTargetIds
)
if (!expectedRevisions) {
return []
}
const expectedHostObservationTokens = getExpectedHostObservationTokens(
args.expectedHostObservationTokensByTargetId,
hydratedTargetIds
)
if (!expectedHostObservationTokens) {
return []
}
const targets =
getSshConnectionStore()
?.listTargets()
@@ -151,14 +217,31 @@ export function registerRemoteWorkspaceHandlers(
// Why: each target has its own revision stream. Keep same-target
// writes queued, but do not let one slow relay block others.
const session = exportSessionForTarget(store, target.id, workspaceSession)
const result = await queueRemoteWorkspacePatch(target.id, () =>
patchRemoteWorkspaceSession(target, session)
)
const result = await queueRemoteWorkspacePatch(target.id, async () => {
const current =
getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target))
const expectedRevision = expectedRevisions.get(target.id)
const expectedHostObservationToken = expectedHostObservationTokens.get(target.id)
if (
!current ||
expectedRevision === undefined ||
expectedHostObservationToken === undefined ||
current.hostObservationToken !== expectedHostObservationToken ||
!cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, expectedRevision)
) {
const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current
return latest
? ({ ok: false, reason: 'stale-revision', snapshot: latest } as const)
: null
}
return patchRemoteWorkspaceSession(target, session)
})
return result ? { targetId: target.id, result } : null
})
)
return results.filter(
(entry): entry is { targetId: string; result: RemoteWorkspacePatchResult } => entry !== null
(entry): entry is { targetId: string; result: RemoteWorkspaceObservedPatchResult } =>
entry !== null
)
}
)
@@ -120,9 +120,9 @@ export function handleSshConnectionStateChange(targetId: string, state: SshConne
export function createSshConnectionCallbacks(): SshConnectionCallbacks {
return {
onCredentialRequest: (targetId, kind, detail) => {
onCredentialRequest: (targetId, kind, detail, signal) => {
credentialRequestedForTarget.add(targetId)
return requestCredential(getCurrentMainWindow, targetId, kind, detail)
return requestCredential(getCurrentMainWindow, targetId, kind, detail, signal)
},
onStateChange: handleSshConnectionStateChange
}
+39
View File
@@ -0,0 +1,39 @@
import type { BrowserWindow } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { requestCredential } from './ssh-passphrase'
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
removeHandler: vi.fn()
}
}))
function credentialWindow() {
return {
isDestroyed: () => false,
webContents: { send: vi.fn() }
} as unknown as BrowserWindow
}
describe('SSH credential requests', () => {
it('resolves and removes the renderer prompt when its connection aborts', async () => {
const window = credentialWindow()
const controller = new AbortController()
const pending = requestCredential(
() => window,
'target-1',
'keyboard-interactive',
'Duo response',
controller.signal
)
const request = vi.mocked(window.webContents.send).mock.calls[0][1] as { requestId: string }
controller.abort()
await expect(pending).resolves.toBeNull()
expect(window.webContents.send).toHaveBeenLastCalledWith('ssh:credential-resolved', {
requestId: request.requestId
})
})
})
+31 -35
View File
@@ -1,8 +1,6 @@
import { ipcMain, type BrowserWindow } from 'electron'
import { randomUUID } from 'node:crypto'
import type { SshCredentialKind } from '../ssh/ssh-connection-utils'
const CREDENTIAL_TIMEOUT_MS = 120_000
import { SSH_CREDENTIAL_TIMEOUT_MS, type SshCredentialKind } from '../ssh/ssh-connection-utils'
const pendingRequests = new Map<string, { resolve: (value: string | null) => void }>()
function notifyCredentialResolved(
@@ -19,47 +17,45 @@ export function requestCredential(
getMainWindow: () => BrowserWindow | null,
targetId: string,
kind: SshCredentialKind,
detail: string
detail: string,
signal?: AbortSignal
): Promise<string | null> {
const requestId = randomUUID()
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (pendingRequests.delete(requestId)) {
notifyCredentialResolved(getMainWindow, requestId)
resolve(null)
}
}, CREDENTIAL_TIMEOUT_MS)
pendingRequests.set(requestId, {
resolve: (value) => {
clearTimeout(timer)
resolve(value)
}
})
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail })
} else {
pendingRequests.delete(requestId)
clearTimeout(timer)
notifyCredentialResolved(getMainWindow, requestId)
resolve(null)
const { promise, resolve } = Promise.withResolvers<string | null>()
let timer: ReturnType<typeof setTimeout>
const finish = (value: string | null): void => {
if (!pendingRequests.delete(requestId)) {
return
}
})
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
notifyCredentialResolved(getMainWindow, requestId)
resolve(value)
}
const onAbort = (): void => finish(null)
timer = setTimeout(() => finish(null), SSH_CREDENTIAL_TIMEOUT_MS)
pendingRequests.set(requestId, { resolve: finish })
if (signal?.aborted) {
finish(null)
return promise
}
signal?.addEventListener('abort', onAbort, { once: true })
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail })
} else {
finish(null)
}
return promise
}
export function registerCredentialHandler(getMainWindow: () => BrowserWindow | null): void {
export function registerCredentialHandler(): void {
ipcMain.removeHandler('ssh:submitCredential')
ipcMain.handle(
'ssh:submitCredential',
(_event, args: { requestId: string; value: string | null }) => {
const pending = pendingRequests.get(args.requestId)
if (pending) {
pendingRequests.delete(args.requestId)
notifyCredentialResolved(getMainWindow, args.requestId)
pending.resolve(args.value)
}
pendingRequests.get(args.requestId)?.resolve(args.value)
}
)
}
+1 -1
View File
@@ -161,7 +161,7 @@ export function registerSshHandlers(
setPersistedStore(store)
registerAdvertisedUrlRefresh(getCurrentMainWindow)
registerCredentialHandler(getCurrentMainWindow)
registerCredentialHandler()
const callbacks = createSshConnectionCallbacks()
if (connectionManager) {
+9 -5
View File
@@ -53,13 +53,16 @@ export class MacosSystemSleepAssertion {
this.spawn = options.spawn ?? nodeSpawn
}
start(reason: string): void {
if (this.platform !== 'darwin' || this.child) {
return
start(reason: string): boolean {
if (this.platform !== 'darwin') {
return false
}
if (this.child) {
return true
}
if (this.retryNotBefore !== null && this.now() < this.retryNotBefore) {
this.scheduleRetry()
return
return false
}
let child: CaffeinateProcess
@@ -70,7 +73,7 @@ export class MacosSystemSleepAssertion {
})
} catch (error) {
this.handleFailure('spawn-error', reason, error)
return
return false
}
this.child = child
@@ -91,6 +94,7 @@ export class MacosSystemSleepAssertion {
child.on('exit', onExit)
this.resetRetrySuppression()
this.resetFailureStreak()
return true
}
stop(_reason: string): void {
@@ -171,6 +171,9 @@ describe('Store SSH pending PTY kills', () => {
.getSshRemotePtyLeases('ssh-1')
.filter((lease) => lease.pendingKill !== undefined)
expect(persisted).toHaveLength(MAX_SSH_PENDING_PTY_KILLS_PER_TARGET)
expect(reloaded.getSshRemotePtyLeases('ssh-1')).toHaveLength(
MAX_SSH_PENDING_PTY_KILLS_PER_TARGET
)
// Newest kept: the oldest orders are the ones least likely to still name a live process.
expect(persisted.some((lease) => lease.ptyId === `pty-${total - 1}`)).toBe(true)
expect(persisted.some((lease) => lease.ptyId === 'pty-0')).toBe(false)
@@ -197,6 +200,40 @@ describe('Store SSH pending PTY kills', () => {
})
})
it('starts a fresh TTL and attempt count when a relay id names a new incarnation', async () => {
const store = await createStore()
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
requestedAt: NOW,
incarnationId: 'inc-a',
attempts: 0
})
store.noteSshRemotePtyKillReplayAttempt('ssh-1', 'pty-1')
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
requestedAt: NOW + 5000,
incarnationId: 'inc-b',
attempts: 0
})
expect(store.getSshRemotePtyKillIntents('ssh-1', NOW)[0]?.intent).toEqual({
requestedAt: NOW + 5000,
incarnationId: 'inc-b',
attempts: 0
})
})
it('removes a synthetic lease when its only pending intent expires', async () => {
const store = await createStore()
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
requestedAt: NOW,
incarnationId: 'inc-a',
attempts: 0
})
store.pruneExpiredSshRemotePtyKillIntents('ssh-1', NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1)
expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([])
})
it('scopes intents to their own target', async () => {
const store = await createStore()
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
@@ -0,0 +1,391 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore } from './persistence-test-harness'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => {
const decoded = ciphertext.toString('utf-8')
if (!decoded.startsWith('encrypted:')) {
throw new Error('invalid ciphertext')
}
return decoded.slice('encrypted:'.length)
}
}
}))
vi.mock('./telemetry/client', () => ({
track: trackMock
}))
vi.mock('./telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
describe('Store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('retains an SSH host binding when a stale renderer clears its pty map', async () => {
const store = await createStore()
const hostId = 'ssh:ssh-1'
const session = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'ssh:ssh-1@@old'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf' as const, leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@old' }
}
}
}
store.setWorkspaceSession(session, hostId)
store.upsertSshRemotePtyLease({
targetId: 'ssh-1',
ptyId: 'old',
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
state: 'detached'
})
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0], ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: {
...session.terminalLayoutsByTabId.tab1,
ptyIdsByLeafId: {}
}
}
},
hostId
)
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
[TEST_LEAF_1]: 'ssh:ssh-1@@old'
})
})
it('does not replay a scoped SSH binding from a different host partition', async () => {
const store = await createStore()
const hostId = 'ssh:ssh-1'
const session: WorkspaceSessionState = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'ssh:ssh-2@@foreign'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-2@@foreign' }
}
}
}
store.setWorkspaceSession(session, hostId)
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} }
}
},
hostId
)
const persisted = store.getWorkspaceSession(hostId)
expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull()
expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({})
})
it('retains a runtime host binding when no death evidence exists', async () => {
const store = await createStore()
const hostId = 'runtime:env-1'
const session: WorkspaceSessionState = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'runtime-pty'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'runtime-pty' }
}
}
}
store.setWorkspaceSession(session, hostId)
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} }
}
},
hostId
)
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
[TEST_LEAF_1]: 'runtime-pty'
})
})
it('does not resurrect a host binding after its SSH lease expires', async () => {
const store = await createStore()
const hostId = 'ssh:ssh-1'
const session: WorkspaceSessionState = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'ssh:ssh-1@@expired'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@expired' }
}
}
}
store.setWorkspaceSession(session, hostId)
store.upsertSshRemotePtyLease({
targetId: 'ssh-1',
ptyId: 'expired',
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
state: 'expired'
})
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} }
}
},
hostId
)
const persisted = store.getWorkspaceSession(hostId)
expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull()
expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({})
})
it('retains surviving leaves while ignoring bindings for removed leaves', async () => {
const store = await createStore()
const hostId = 'runtime:env-1'
const session: WorkspaceSessionState = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'runtime-pty-1'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: TEST_LEAF_1 },
second: { type: 'leaf', leafId: TEST_LEAF_2 }
},
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: {
[TEST_LEAF_1]: 'runtime-pty-1',
[TEST_LEAF_2]: 'runtime-pty-2'
}
}
}
}
store.setWorkspaceSession(session, hostId)
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: {
...session.terminalLayoutsByTabId.tab1!,
root: { type: 'leaf', leafId: TEST_LEAF_2 },
activeLeafId: TEST_LEAF_2,
ptyIdsByLeafId: {}
}
}
},
hostId
)
const persisted = store.getWorkspaceSession(hostId)
expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull()
expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
[TEST_LEAF_2]: 'runtime-pty-2'
})
})
it('does not restore a binding with an explicit SSH termination tombstone', async () => {
const store = await createStore()
const hostId = 'ssh:ssh-1'
const session: WorkspaceSessionState = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'ssh:ssh-1@@closed'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@closed' }
}
}
}
store.setWorkspaceSession(session, hostId)
store.upsertSshRemotePtyLease({
targetId: 'ssh-1',
ptyId: 'closed',
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
state: 'terminated'
})
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }]
},
terminalLayoutsByTabId: {
tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} }
}
},
hostId
)
const persisted = store.getWorkspaceSession(hostId)
expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull()
expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({})
})
})
@@ -10,6 +10,16 @@ import {
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import type { SshPtyLeaseOperations } from './ssh-pty-lease-operations'
function isDisposableKillOnlyLease(lease: SshRemotePtyLease): boolean {
return (
lease.pendingKill === undefined &&
(lease.state === 'terminated' || lease.state === 'expired') &&
lease.worktreeId === undefined &&
lease.tabId === undefined &&
lease.leafId === undefined
)
}
/** Every recorded-but-undelivered stop for a target, newest first, TTL-filtered and capped.
* Returned with stored (target-local) relay pty ids, which is what `pty.shutdown` takes. */
export function getSshRemotePtyKillIntents(
@@ -32,7 +42,8 @@ export function pruneExpiredSshRemotePtyKillIntents(
now: number
): void {
let changed = false
for (const lease of operations.state.sshRemotePtyLeases ?? []) {
const leases = operations.state.sshRemotePtyLeases ?? []
for (const lease of leases) {
if (
lease.targetId === targetId &&
lease.pendingKill &&
@@ -44,6 +55,9 @@ export function pruneExpiredSshRemotePtyKillIntents(
}
}
if (changed) {
operations.state.sshRemotePtyLeases = leases.filter(
(lease) => !isDisposableKillOnlyLease(lease)
)
operations.flush()
}
}
@@ -62,10 +76,21 @@ function capPendingKillsForTarget(
const kept = new Set(
prunePendingSshPtyKills(pendingSshPtyKillEntries(scoped), now).map((entry) => entry.ptyId)
)
const disposable = new Set<SshRemotePtyLease>()
for (const lease of scoped) {
if (!kept.has(lease.ptyId)) {
delete lease.pendingKill
lease.updatedAt = now
if (isDisposableKillOnlyLease(lease)) {
disposable.add(lease)
}
}
}
if (disposable.size > 0) {
for (let index = leases.length - 1; index >= 0; index -= 1) {
if (disposable.has(leases[index])) {
leases.splice(index, 1)
}
}
}
}
@@ -89,13 +114,16 @@ export function recordSshRemotePtyKillIntent(
const leases = operations.state.sshRemotePtyLeases
const existing = leases.find((entry) => entry.targetId === targetId && entry.ptyId === relayPtyId)
if (existing) {
// Why keep the earliest requestedAt: the TTL bounds how long the intent may chase the host, and
// a repeated close must not extend it. Attempts carry over so replays stay countable.
existing.pendingKill = {
...intent,
requestedAt: Math.min(existing.pendingKill?.requestedAt ?? now, now),
attempts: existing.pendingKill?.attempts ?? intent.attempts
}
const prior = existing.pendingKill
// Same incarnation means a repeated close; a recycled relay id starts a new intent lifetime.
existing.pendingKill =
prior?.incarnationId === intent.incarnationId
? {
...intent,
requestedAt: Math.min(prior.requestedAt, now),
attempts: prior.attempts
}
: intent
existing.updatedAt = now
} else {
leases.push({
@@ -119,14 +147,20 @@ export function clearSshRemotePtyKillIntent(
ptyId: string
): void {
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
const lease = (operations.state.sshRemotePtyLeases ?? []).find(
const leases = operations.state.sshRemotePtyLeases ?? []
const leaseIndex = leases.findIndex(
(entry) => entry.targetId === targetId && entry.ptyId === relayPtyId
)
const lease = leases[leaseIndex]
if (!lease?.pendingKill) {
return
}
delete lease.pendingKill
lease.updatedAt = Date.now()
if (isDisposableKillOnlyLease(lease)) {
leases.splice(leaseIndex, 1)
} else {
lease.updatedAt = Date.now()
}
operations.flush()
}
@@ -53,6 +53,8 @@ export class PtyBindingPersistenceOperations {
* Callers pass false only once absence is meaningful; see the relay's reattach bind.
*/
mayCreate?: boolean
/** Reattach must not revive a surface a prior build durably recorded as retired. */
mayReviveRetiredSurface?: boolean
},
hostId?: string | null
): boolean {
@@ -97,6 +99,12 @@ export class PtyBindingPersistenceOperations {
// Decided before any mutation so a refusal leaves nothing half-written. Mirrors the four
// creating branches below — mint a tab, mint a root leaf, split the root and graft a leaf,
// mint a layout — each of which sets `terminalMembershipChanged`.
if (
args.mayReviveRetiredSurface === false &&
session.terminalSurfaceTombstonesByPaneKey?.[paneKey]
) {
return false
}
if (args.mayCreate === false) {
const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find(
(candidate) => candidate.id === args.tabId
@@ -21,6 +21,11 @@ import {
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import {
preserveMissingWorkspaceSessionTerminalBindings,
sshTargetIdForWorkspaceSessionHost
} from './workspace-session-terminal-binding-replay'
import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery'
type SessionHostPartitionOperationsRuntime = Pick<
StoreRuntimeState,
@@ -31,6 +36,7 @@ const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperat
type SessionHostPartitionOperationsContext = {
runtime: SessionHostPartitionOperationsRuntime
scheduling: WriteSchedulingOperations
bindingRecovery: TerminalBindingRecoveryOperations
}
export class SessionHostPartitionOperations {
@@ -38,9 +44,10 @@ export class SessionHostPartitionOperations {
constructor(
runtime: SessionHostPartitionOperationsRuntime,
scheduling: WriteSchedulingOperations
scheduling: WriteSchedulingOperations,
bindingRecovery: TerminalBindingRecoveryOperations
) {
this[sessionHostPartitionOperationsContext] = { runtime, scheduling }
this[sessionHostPartitionOperationsContext] = { runtime, scheduling, bindingRecovery }
}
getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] {
@@ -163,16 +170,21 @@ export function setHostWorkspaceSession(
hostId: ExecutionHostId,
session: WorkspaceSessionState
): void {
const prior =
owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId]
// Why here and not at the callers: the before-unload stage path writes the renderer's payload
// straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows.
session = preserveRuntimeAuthoredWorkspaceSessionFields(
session,
owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId]
)
session = preserveRuntimeAuthoredWorkspaceSessionFields(session, prior)
// Why: each partition owns its topology fence; renderer writes omit it and must rebase locally.
session = sanitizeWorkspaceSessionTerminalRetirements(
session = sanitizeWorkspaceSessionTerminalRetirements(session, prior)
session = preserveMissingWorkspaceSessionTerminalBindings(
session,
owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId]
prior,
owner[sessionHostPartitionOperationsContext].bindingRecovery,
{
targetIdForWorktree: sshTargetIdForWorkspaceSessionHost(hostId),
executionHostId: hostId
}
)
const pruned = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(
@@ -139,7 +139,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains {
const preferences = new ProfilePreferences(runtime, scheduling)
const repos = new RepoLifecycleOperations(runtime, scheduling)
const bindingRecovery = new TerminalBindingRecoveryOperations(runtime)
const sessions = new SessionHostPartitionOperations(runtime, scheduling)
const sessions = new SessionHostPartitionOperations(runtime, scheduling, bindingRecovery)
const sessionSnapshots = new SessionSnapshotOperations(
runtime,
sessions,
@@ -11,7 +11,6 @@ import {
migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync
} from '../../terminal-scrollback-snapshot-async-migration'
import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields'
import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization'
import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization'
import {
normalizeWorkspaceSessionPaneIdentities,
@@ -20,6 +19,7 @@ import {
type WorkspaceSessionPaneIdentityRemap
} from '../restoring-sessions/workspace-pane-normalization'
import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup'
import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay'
import {
getSessionSnapshotOperationsContext,
type SessionSnapshotOperations
@@ -71,105 +71,7 @@ export function setLocalWorkspaceSession(
if (remappedLeases.changed) {
context.runtime.state.sshRemotePtyLeases = remappedLeases.leases
}
if (session && prior) {
const priorTabs = prior.tabsByWorktree ?? {}
const nextTabs = session.tabsByWorktree ?? {}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
for (const [worktreeId, tabs] of Object.entries(nextTabs)) {
const priorList = priorTabs[worktreeId]
if (!priorList) {
continue
}
for (const tab of tabs) {
if (tab.ptyId) {
continue
}
const priorTab = priorList.find((t) => t.id === tab.id)
if (
priorTab?.ptyId &&
context.bindingRecovery.isRestorablePtyBinding({
ptyId: priorTab.ptyId,
worktreeId,
targetId: context.bindingRecovery.getConnectionIdForWorktree(worktreeId),
tabId: tab.id
})
) {
tab.ptyId = priorTab.ptyId
}
}
}
const priorLayouts = prior.terminalLayoutsByTabId ?? {}
const nextLayouts = session.terminalLayoutsByTabId ?? {}
for (const [tabId, layout] of Object.entries(nextLayouts)) {
const priorLayout = priorLayouts[tabId]
if (!priorLayout?.ptyIdsByLeafId) {
continue
}
const incoming = layout.ptyIdsByLeafId ?? {}
const incomingHasAnyBinding = Object.keys(incoming).length > 0
const liveLeafIds = context.bindingRecovery.getTerminalLayoutLeafIds(layout.root)
const worktreeId = worktreeIdByTabId.get(tabId)
const targetId = worktreeId
? context.bindingRecovery.getConnectionIdForWorktree(worktreeId)
: null
const restorableBindings = Object.fromEntries(
Object.entries(priorLayout.ptyIdsByLeafId).filter(
([leafId, ptyId]) =>
liveLeafIds.has(leafId) &&
incoming[leafId] === undefined &&
// Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it.
(incomingHasAnyBinding
? context.bindingRecovery.hasRestorableSshRemotePtyLease({
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
: context.bindingRecovery.isRestorablePtyBinding({
ptyId,
targetId,
worktreeId,
tabId,
leafId
}))
)
)
if (Object.keys(restorableBindings).length > 0) {
layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming }
// Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata.
const buffersByLeafId = preserveMissingLeafRecordEntries(
priorLayout.buffersByLeafId,
layout.buffersByLeafId,
liveLeafIds
)
const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries(
priorLayout.scrollbackRefsByLeafId,
layout.scrollbackRefsByLeafId,
liveLeafIds
)
const titlesByLeafId = preserveMissingLeafRecordEntries(
priorLayout.titlesByLeafId,
layout.titlesByLeafId,
liveLeafIds
)
if (buffersByLeafId) {
layout.buffersByLeafId = buffersByLeafId
}
if (scrollbackRefsByLeafId) {
layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId
}
if (titlesByLeafId) {
layout.titlesByLeafId = titlesByLeafId
}
}
}
}
session = preserveMissingWorkspaceSessionTerminalBindings(session, prior, context.bindingRecovery)
session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos)
if (!deferSnapshotFiles) {
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay'
const LEAF_ONE = '11111111-1111-4111-8111-111111111111'
const LEAF_TWO = '22222222-2222-4222-8222-222222222222'
const WORKTREE_A = 'worktree-a'
const WORKTREE_B = 'worktree-b'
function session(ptyId: string | null): WorkspaceSessionState {
return {
activeRepoId: 'repo',
activeWorktreeId: 'worktree',
activeTabId: 'tab',
tabsByWorktree: {
worktree: [
{
id: 'tab',
worktreeId: 'worktree',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId
}
]
},
terminalLayoutsByTabId: {}
}
}
function terminalTab(worktreeId: string, id: string, ptyId: string | null) {
return {
id,
worktreeId,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId
}
}
const bindingRecovery = {
getTerminalLayoutLeafIds: (root: { leafId?: string } | null) =>
new Set(root?.leafId ? [root.leafId] : []),
getConnectionIdForWorktree: () => null,
isRestorablePtyBinding: () => true,
hasRestorableSshRemotePtyLease: () => false
}
describe('workspace session terminal binding replay', () => {
it('retains a restorable legacy tab binding when neither snapshot has a layout', () => {
const prior = session('runtime-pty')
const incoming = session(null)
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBe('runtime-pty')
})
it('does not retain a tab binding whose prior leaf was removed from the layout', () => {
const prior = session('runtime-pty')
prior.terminalLayoutsByTabId.tab = {
root: { type: 'leaf', leafId: LEAF_ONE },
activeLeafId: LEAF_ONE,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ONE]: 'runtime-pty' }
}
const incoming = session(null)
incoming.terminalLayoutsByTabId.tab = {
root: { type: 'leaf', leafId: LEAF_TWO },
activeLeafId: LEAF_TWO,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBeNull()
})
it('fails closed when one tab id is duplicated across worktrees', () => {
const prior = session(null)
prior.tabsByWorktree = {
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-a')],
[WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', 'pty-b')]
}
prior.terminalLayoutsByTabId = {
'duplicate-tab': {
root: { type: 'leaf', leafId: LEAF_ONE },
activeLeafId: LEAF_ONE,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' }
}
}
const incoming = session(null)
incoming.tabsByWorktree = {
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)],
[WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', null)]
}
incoming.terminalLayoutsByTabId = {
'duplicate-tab': {
root: { type: 'leaf', leafId: LEAF_ONE },
activeLeafId: LEAF_ONE,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
}
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull()
expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBeNull()
expect(incoming.terminalLayoutsByTabId['duplicate-tab']?.ptyIdsByLeafId).toEqual({})
})
it('still replays bindings for distinct tab ids in separate worktrees', () => {
const prior = session(null)
prior.tabsByWorktree = {
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', 'pty-a')],
[WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', 'pty-b')]
}
prior.terminalLayoutsByTabId = {
'tab-a': {
root: { type: 'leaf', leafId: LEAF_ONE },
activeLeafId: LEAF_ONE,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' }
},
'tab-b': {
root: { type: 'leaf', leafId: LEAF_TWO },
activeLeafId: LEAF_TWO,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_TWO]: 'pty-b' }
}
}
const incoming = session(null)
incoming.tabsByWorktree = {
[WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', null)],
[WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', null)]
}
incoming.terminalLayoutsByTabId = {
'tab-a': {
root: { type: 'leaf', leafId: LEAF_ONE },
activeLeafId: LEAF_ONE,
expandedLeafId: null,
ptyIdsByLeafId: {}
},
'tab-b': {
root: { type: 'leaf', leafId: LEAF_TWO },
activeLeafId: LEAF_TWO,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
}
preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never)
expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBe('pty-a')
expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBe('pty-b')
expect(incoming.terminalLayoutsByTabId['tab-a']?.ptyIdsByLeafId).toEqual({
[LEAF_ONE]: 'pty-a'
})
expect(incoming.terminalLayoutsByTabId['tab-b']?.ptyIdsByLeafId).toEqual({
[LEAF_TWO]: 'pty-b'
})
})
})
@@ -0,0 +1,220 @@
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { getPtyExecutionHost } from '../../../shared/terminal-execution-host'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization'
import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery'
type TerminalBindingRecovery = Pick<
TerminalBindingRecoveryOperations,
| 'getTerminalLayoutLeafIds'
| 'getConnectionIdForWorktree'
| 'isRestorablePtyBinding'
| 'hasRestorableSshRemotePtyLease'
>
/** A bare tab id cannot select one binding when persisted rows disagree on its worktree. */
function collectAmbiguousTabIds(
tabsByWorktree: WorkspaceSessionState['tabsByWorktree']
): ReadonlySet<string> {
const ownerByTabId = new Map<string, string>()
const ambiguous = new Set<string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
for (const tab of tabs) {
if (ownerByTabId.has(tab.id)) {
ambiguous.add(tab.id)
} else {
ownerByTabId.set(tab.id, worktreeId)
}
}
}
return ambiguous
}
export type WorkspaceSessionTerminalBindingReplayOptions = {
/** Resolve the SSH target that owns bindings in a host partition. */
targetIdForWorktree?: (worktreeId: string) => string | null
/** Reject a binding that explicitly names another execution host. */
executionHostId?: ExecutionHostId
}
function ptyBindingMatchesExecutionHost(
ptyId: string,
executionHostId: ExecutionHostId | undefined
): boolean {
if (!executionHostId || executionHostId === LOCAL_EXECUTION_HOST_ID) {
return true
}
const owner = getPtyExecutionHost(ptyId)
// Legacy unscoped ids carry no host proof; lease/context checks below still
// decide whether they are restorable. Known foreign ids must never cross a partition.
return owner === null || owner === executionHostId
}
/**
* Reapplies durable pane bindings omitted by an older or in-flight renderer snapshot.
*
* An empty binding map is ambiguous: it can be a pre-spawn snapshot, or an intentional close.
* Lease/tombstone state is the authority that distinguishes those cases. A partial map is only
* repaired when a live SSH lease proves the omitted sibling still belongs to this host.
*/
export function preserveMissingWorkspaceSessionTerminalBindings(
session: WorkspaceSessionState,
prior: WorkspaceSessionState | undefined,
bindingRecovery: TerminalBindingRecovery,
options: WorkspaceSessionTerminalBindingReplayOptions = {}
): WorkspaceSessionState {
if (!prior) {
return session
}
const priorTabs = prior.tabsByWorktree ?? {}
const nextTabs = session.tabsByWorktree ?? {}
const priorLayouts = prior.terminalLayoutsByTabId ?? {}
const nextLayouts = session.terminalLayoutsByTabId ?? {}
// Both snapshots are independently allowed to contain the same id during a
// normal worktree move. Only ids duplicated within one snapshot are unsafe;
// skip their replay rather than assigning a global layout to an arbitrary row.
const ambiguousTabIds = new Set<string>([
...collectAmbiguousTabIds(priorTabs),
...collectAmbiguousTabIds(nextTabs)
])
const targetIdForWorktree =
options.targetIdForWorktree ??
((worktreeId: string) => bindingRecovery.getConnectionIdForWorktree(worktreeId))
// Keep a tab-level binding when the renderer has not observed the host's spawn yet.
for (const [worktreeId, tabs] of Object.entries(nextTabs)) {
const priorList = priorTabs[worktreeId]
if (!priorList) {
continue
}
for (const tab of tabs) {
if (ambiguousTabIds.has(tab.id)) {
continue
}
if (tab.ptyId) {
continue
}
const priorTab = priorList.find((candidate) => candidate.id === tab.id)
const incomingLayout = nextLayouts[tab.id]
const priorLayout = priorLayouts[tab.id]
const priorPtyLeafId = priorLayout
? Object.entries(priorLayout.ptyIdsByLeafId ?? {}).find(
([, ptyId]) => ptyId === priorTab?.ptyId
)?.[0]
: undefined
const bindingLeafWasRemoved =
incomingLayout !== undefined &&
priorPtyLeafId !== undefined &&
!bindingRecovery.getTerminalLayoutLeafIds(incomingLayout.root).has(priorPtyLeafId)
if (
priorTab?.ptyId &&
!bindingLeafWasRemoved &&
ptyBindingMatchesExecutionHost(priorTab.ptyId, options.executionHostId) &&
bindingRecovery.isRestorablePtyBinding({
ptyId: priorTab.ptyId,
worktreeId,
targetId: targetIdForWorktree(worktreeId),
tabId: tab.id
})
) {
tab.ptyId = priorTab.ptyId
}
}
}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
for (const [tabId, layout] of Object.entries(nextLayouts)) {
if (ambiguousTabIds.has(tabId)) {
continue
}
const priorLayout = priorLayouts[tabId]
if (!priorLayout?.ptyIdsByLeafId) {
continue
}
const incoming = layout.ptyIdsByLeafId ?? {}
const incomingHasAnyBinding = Object.keys(incoming).length > 0
const liveLeafIds = bindingRecovery.getTerminalLayoutLeafIds(layout.root)
const worktreeId = worktreeIdByTabId.get(tabId)
const targetId = worktreeId ? targetIdForWorktree(worktreeId) : null
const restorableBindings = Object.fromEntries(
Object.entries(priorLayout.ptyIdsByLeafId).filter(
([leafId, ptyId]) =>
liveLeafIds.has(leafId) &&
incoming[leafId] === undefined &&
ptyBindingMatchesExecutionHost(ptyId, options.executionHostId) &&
// An empty map may be a stale pre-spawn snapshot; a partial map is intentional unless
// a durable SSH lease proves the omitted sibling is still live on this host.
(incomingHasAnyBinding
? bindingRecovery.hasRestorableSshRemotePtyLease({
ptyId,
targetId,
worktreeId,
tabId,
leafId
})
: bindingRecovery.isRestorablePtyBinding({
ptyId,
targetId,
worktreeId,
tabId,
leafId
}))
)
)
if (Object.keys(restorableBindings).length === 0) {
continue
}
layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming }
// Keep pane metadata alongside a binding rescued from a stale renderer write.
const buffersByLeafId = preserveMissingLeafRecordEntries(
priorLayout.buffersByLeafId,
layout.buffersByLeafId,
liveLeafIds
)
const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries(
priorLayout.scrollbackRefsByLeafId,
layout.scrollbackRefsByLeafId,
liveLeafIds
)
const titlesByLeafId = preserveMissingLeafRecordEntries(
priorLayout.titlesByLeafId,
layout.titlesByLeafId,
liveLeafIds
)
if (buffersByLeafId) {
layout.buffersByLeafId = buffersByLeafId
}
if (scrollbackRefsByLeafId) {
layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId
}
if (titlesByLeafId) {
layout.titlesByLeafId = titlesByLeafId
}
}
return session
}
/** Target resolver for a persisted execution-host partition. */
export function sshTargetIdForWorkspaceSessionHost(
hostId: ExecutionHostId
): ((worktreeId: string) => string | null) | undefined {
if (hostId === LOCAL_EXECUTION_HOST_ID) {
return undefined
}
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'ssh' ? () => parsed.targetId : () => null
}
+6 -1
View File
@@ -199,7 +199,12 @@ export type IPtyProvider = {
// deadline; each RPC leaf converts to a relative timeout when it actually issues.
shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
opts: {
immediate?: boolean
keepHistory?: boolean
deadlineMs?: number
expectedIncarnationId?: PtyIncarnationId
}
): Promise<void>
sendSignal(id: string, signal: string): Promise<void>
getCwd(id: string): Promise<string>
+1 -1
View File
@@ -14,7 +14,7 @@ export type PtySpawnResult = {
incarnationId?: PtyIncarnationId
/** Relay source identity installed before adjacent source frames are decoded. */
sourceActivation?: PtySourceReceivingActivation
/** The provider observed this exact spawn exit before its control reply settled. */
/** The provider observed this exact spawn exit before returning its spawn result. */
exitedBeforeSpawnReply?: true
/** OS-level pid of the shell process, when available at spawn time.
* Why: the memory collector needs this to walk each PTY's process
@@ -184,6 +184,24 @@ describe('SshPtyProvider', () => {
)
})
it('shutdown forwards the expected PTY incarnation over the relay', async () => {
await provider.shutdown(scopedPty1, {
immediate: true,
expectedIncarnationId: 'incarnation-1'
})
expectRequest(
mux.request,
'pty.shutdown',
{
id: 'pty-1',
immediate: true,
keepHistory: false,
expectedIncarnationId: 'incarnation-1'
},
undefined
)
})
it('shutdown bounds the relay RPC by the teardown deadline', async () => {
// Why: freeze Date.now() so the leaf conversion deadline -> remaining relative
// timeout is exact and the mux receives precisely the leftover budget.
+5 -5
View File
@@ -205,16 +205,16 @@ export class SshPtyProvider implements IPtyProvider {
this.mux.notify('pty.resize', { id: this.toRelayPtyId(id), cols, rows })
}
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
async shutdown(id: string, opts: Parameters<IPtyProvider['shutdown']>[1]): Promise<void> {
await this.mux.request(
'pty.shutdown',
{
id: this.toRelayPtyId(id),
immediate: opts.immediate ?? false,
keepHistory: opts.keepHistory ?? false
keepHistory: opts.keepHistory ?? false,
...(opts.expectedIncarnationId === undefined
? {}
: { expectedIncarnationId: opts.expectedIncarnationId })
},
relayTimeoutOptions(opts.deadlineMs)
)
@@ -2,6 +2,7 @@ import {
copyFileSync,
existsSync,
linkSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -79,8 +80,12 @@ async function runPty(options: {
proc.onData((data) => {
output += data
})
let exited = false
const exitPromise = new Promise<number>((resolve) => {
proc.onExit(({ exitCode }) => resolve(exitCode))
proc.onExit(({ exitCode }) => {
exited = true
resolve(exitCode)
})
})
let timeout: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_resolve, reject) => {
@@ -101,10 +106,12 @@ async function runPty(options: {
if (timeout) {
clearTimeout(timeout)
}
try {
proc.kill()
} catch {
// The PTY may already have exited.
if (!exited) {
try {
proc.kill()
} catch {
// The PTY may have exited while cleanup was starting.
}
}
}
}
@@ -155,10 +162,14 @@ describeWindows('Windows Codex shell preflight runtime', () => {
const root = makeTempDir()
const preflight = writeFailingPreflight(root)
const codexExecutable = join(root, 'codex.exe')
// Keep the fixture ahead of any host-global Codex installation in Git Bash.
// A `.local` segment is rewritten by MSYS when it converts temporary paths.
const codexExecutable = join(root, 'bin', 'codex.exe')
mkdirSync(join(root, 'bin'), { recursive: true })
linkNodeExecutable(codexExecutable)
const preflightMarker = join(root, 'git-bash-preflight-ran')
const codexMarker = join(root, 'git-bash-codex-ran')
const codexPathMarker = join(root, 'git-bash-codex-path')
const previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = join(root, 'user data')
@@ -176,7 +187,7 @@ describeWindows('Windows Codex shell preflight runtime', () => {
shellArgs: resolved.shellArgs,
cwd: root,
env: {
...withPathEntry(process.env, root),
...withPathEntry(process.env, join(root, 'bin')),
CHERE_INVOKING: '1',
HOME: root,
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
@@ -185,7 +196,7 @@ describeWindows('Windows Codex shell preflight runtime', () => {
TERM: 'xterm-256color'
},
input:
"codex -e \"require('node:fs').writeFileSync(process.env.ORCA_CODEX_MARKER,'ran')\"\nexit\n",
"type -P codex > git-bash-codex-path\ncodex -e \"require('node:fs').writeFileSync(process.env.ORCA_CODEX_MARKER,'ran')\"\nexit\n",
// Paired "Windows low spec" QA measured 12.715.8s across four runs: Git Bash
// cold-starts two large Node executables for AV scanning, so allow 25s without
// inflating the faster cmd.exe budget.
@@ -200,6 +211,11 @@ describeWindows('Windows Codex shell preflight runtime', () => {
}
expect(existsSync(preflightMarker)).toBe(true)
const resolvedCodexPath = readFileSync(codexPathMarker, 'utf8')
.trim()
.replaceAll('\\', '/')
.toLowerCase()
expect(resolvedCodexPath).toMatch(/\/bin\/codex(?:\.exe)?$/)
expect(readFileSync(codexMarker, 'utf8')).toBe('ran')
})
})
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
const WT = 'repo-1::/home/orca/worktree'
const GROUP = `headless-terminals:${WT}`
const terminalTab = (n: number, isActive = false): RuntimeMobileSessionTerminalTab => ({
type: 'terminal',
id: `tab-${n}::leaf-${n}`,
title: `terminal ${n}`,
parentTabId: `tab-${n}`,
leafId: `leaf-${n}`,
ptyId: `pty-${n}`,
isActive
})
const snapshotOf = (
tabs: RuntimeMobileSessionSnapshotTab[],
tabOrder: string[],
activeTabId: string | null
): RuntimeMobileSessionTabsSnapshot => ({
worktree: WT,
publicationEpoch: 'headless:seed',
snapshotVersion: 1,
activeGroupId: GROUP,
activeTabId,
activeTabType: 'terminal',
tabGroups: [{ id: GROUP, activeTabId: activeTabId?.split('::')[0] ?? null, tabOrder }],
tabs
})
describe('headless tab order stability', () => {
it('retains order when activating a re-appended surface', () => {
const runtime = new OrcaRuntimeService(null) as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
activateHeadlessMobileSessionTerminalTab: (
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
activeTab: RuntimeMobileSessionTerminalTab
) => void
emitMobileSessionTabsSnapshot: (snapshot: RuntimeMobileSessionTabsSnapshot) => void
persistHeadlessTerminalActiveLeaf: (...args: unknown[]) => void
}
runtime.emitMobileSessionTabsSnapshot = () => {}
runtime.persistHeadlessTerminalActiveLeaf = () => {}
const tabs = [terminalTab(1), terminalTab(3), terminalTab(4), terminalTab(2, true)]
const snapshot = snapshotOf(tabs, ['tab-1', 'tab-2', 'tab-3', 'tab-4'], 'tab-2::leaf-2')
runtime.mobileSessionTabsByWorktree.set(WT, snapshot)
runtime.activateHeadlessMobileSessionTerminalTab(WT, snapshot, tabs[3]!)
expect(runtime.mobileSessionTabsByWorktree.get(WT)?.tabGroups?.[0]?.tabOrder).toEqual([
'tab-1',
'tab-2',
'tab-3',
'tab-4'
])
})
it('retains stored order when a materialized surface is re-appended', () => {
const runtime = new OrcaRuntimeService(null) as unknown as {
mergeMobileSessionTabGroups: (
worktreeId: string,
groups: { id: string; activeTabId: string | null; tabOrder: string[] }[],
terminalTabs: RuntimeMobileSessionTerminalTab[],
activeTab: RuntimeMobileSessionTerminalTab | null
) => { id: string; tabOrder: string[] }[]
}
const reappended = [terminalTab(1), terminalTab(3), terminalTab(4), terminalTab(2, true)]
const merged = runtime.mergeMobileSessionTabGroups(
WT,
[{ id: GROUP, activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2', 'tab-3', 'tab-4'] }],
reappended,
reappended[3]!
)
expect(merged[0]!.tabOrder).toEqual(['tab-1', 'tab-2', 'tab-3', 'tab-4'])
})
it('appends only genuinely new tabs after retained order', () => {
const runtime = new OrcaRuntimeService(null) as unknown as {
mergeMobileSessionTabGroups: (
worktreeId: string,
groups: { id: string; activeTabId: string | null; tabOrder: string[] }[],
terminalTabs: RuntimeMobileSessionTerminalTab[],
activeTab: RuntimeMobileSessionTerminalTab | null
) => { id: string; tabOrder: string[] }[]
}
const tabs = [terminalTab(3), terminalTab(1), terminalTab(5, true)]
const merged = runtime.mergeMobileSessionTabGroups(
WT,
[{ id: GROUP, activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2', 'tab-3'] }],
tabs,
tabs[2]!
)
expect(merged[0]!.tabOrder).toEqual(['tab-1', 'tab-3', 'tab-5'])
})
it('retains order independently in split groups', () => {
const runtime = new OrcaRuntimeService(null) as unknown as {
buildHeadlessMobileSessionTabGroups: (
worktreeId: string,
tabs: RuntimeMobileSessionSnapshotTab[],
activeTab: RuntimeMobileSessionSnapshotTab | null,
existingGroups?: RuntimeMobileSessionTabsSnapshot['tabGroups']
) => RuntimeMobileSessionTabsSnapshot['tabGroups']
}
const tabs = [terminalTab(2), terminalTab(1), terminalTab(4), terminalTab(3)]
const groups = runtime.buildHeadlessMobileSessionTabGroups(WT, tabs, tabs[0]!, [
{ id: 'left', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-2'] },
{ id: 'right', activeTabId: 'tab-3', tabOrder: ['tab-3', 'tab-4'] }
])
expect(groups?.find((group) => group.id === 'left')?.tabOrder).toEqual(['tab-1', 'tab-2'])
expect(groups?.find((group) => group.id === 'right')?.tabOrder).toEqual(['tab-3', 'tab-4'])
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof'
describe('mobile session terminal retirement proofs', () => {
it('keeps the newest 64 exact identities', () => {
let proofs = appendRetiredTerminalSurfaceProofs(
undefined,
Array.from({ length: 64 }, (_, index) => ({
parentTabId: `tab-${index}`,
leafId: `leaf-${index}`,
ptyId: `pty-${index}`,
terminal: 'term-old',
incarnationId: 'inc-old'
}))
)
proofs = appendRetiredTerminalSurfaceProofs(proofs, [
{
parentTabId: 'tab-new',
leafId: 'leaf-new',
ptyId: 'pty-new',
terminal: 'term-new',
incarnationId: 'inc-new'
}
])
expect(proofs).toHaveLength(64)
expect(proofs[0]?.parentTabId).toBe('tab-1')
expect(proofs.at(-1)).toEqual({
parentTabId: 'tab-new',
leafId: 'leaf-new',
ptyId: 'pty-new',
terminal: 'term-new',
incarnationId: 'inc-new'
})
})
it('preserves each retired leaf identity independently', () => {
const proofs = appendRetiredTerminalSurfaceProofs(undefined, [
{
parentTabId: 'tab-split',
leafId: 'leaf-left',
ptyId: 'pty-left',
terminal: 'term-left',
incarnationId: 'inc-left'
},
{
parentTabId: 'tab-split',
leafId: 'leaf-right',
ptyId: 'pty-right',
terminal: 'term-right',
incarnationId: 'inc-right'
}
])
expect(proofs).toEqual([
expect.objectContaining({ leafId: 'leaf-left', terminal: 'term-left' }),
expect.objectContaining({ leafId: 'leaf-right', terminal: 'term-right' })
])
})
})
@@ -0,0 +1,28 @@
import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types'
const MAX_RETIRED_TERMINAL_SURFACE_PROOFS = 64
export function appendRetiredTerminalSurfaceProofs(
existing: readonly RuntimeMobileSessionRetiredTerminalSurface[] | undefined,
retired: readonly RuntimeMobileSessionRetiredTerminalSurface[]
): RuntimeMobileSessionRetiredTerminalSurface[] {
const next = new Map(
(existing ?? []).map((surface) => [
`${surface.parentTabId}\0${surface.leafId}\0${surface.terminal}`,
surface
])
)
for (const evidence of retired) {
const key = `${evidence.parentTabId}\0${evidence.leafId}\0${evidence.terminal}`
next.delete(key)
next.set(key, evidence)
}
while (next.size > MAX_RETIRED_TERMINAL_SURFACE_PROOFS) {
const oldest = next.keys().next().value
if (typeof oldest !== 'string') {
break
}
next.delete(oldest)
}
return [...next.values()]
}
@@ -1,4 +1,5 @@
import type {
RuntimeMobileSessionRetiredTerminalSurface,
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsSnapshot,
@@ -9,6 +10,7 @@ import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../shared/terminal-tab-types'
import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof'
export type RetiredTerminalSurface = {
worktreeId: string
@@ -193,6 +195,7 @@ export function retireTerminalSurfacesFromSnapshot(args: {
ptyId: string
exactSurfaces?: readonly Pick<RetiredTerminalSurface, 'parentTabId' | 'leafId'>[]
exactOnly?: boolean
retirementProofs?: readonly RuntimeMobileSessionRetiredTerminalSurface[]
}): { snapshot: RuntimeMobileSessionTabsSnapshot; retired: RetiredTerminalSurface[] } | null {
const exactSurfaceKeys = new Set(
(args.exactSurfaces ?? []).map((surface) => `${surface.parentTabId}\0${surface.leafId}`)
@@ -258,6 +261,12 @@ export function retireTerminalSurfacesFromSnapshot(args: {
null
const retainedGroupIds = new Set(tabGroups?.map((group) => group.id) ?? [])
const retired = retiredTabs.map((tab) => ({
worktreeId: args.snapshot.worktree,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
ptyId: args.ptyId
}))
return {
snapshot: {
...args.snapshot,
@@ -274,13 +283,16 @@ export function retireTerminalSurfacesFromSnapshot(args: {
)
}
: {}),
...(args.retirementProofs && args.retirementProofs.length > 0
? {
retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs(
args.snapshot.retiredTerminalSurfaces,
args.retirementProofs
)
}
: {}),
tabs
},
retired: retiredTabs.map((tab) => ({
worktreeId: args.snapshot.worktree,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
ptyId: args.ptyId
}))
retired
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types'
const WORKTREE_A = 'repo-1::/tmp/worktree-a'
const WORKTREE_B = 'repo-1::/tmp/worktree-b'
function tab(tabId: string, worktreeId: string) {
return {
tabId,
worktreeId,
title: `${worktreeId} tab`,
activeLeafId: null,
layout: null
}
}
function graph(tabs: RuntimeSyncWindowGraph['tabs']): RuntimeSyncWindowGraph {
return { tabs, leaves: [] }
}
describe('runtime graph tab identity', () => {
it('does not claim graph authority when the first publication is malformed', () => {
const runtime = new OrcaRuntimeService()
expect(() =>
runtime.syncWindowGraph(
1,
graph([tab('tab-duplicate', WORKTREE_A), tab('tab-duplicate', WORKTREE_B)])
)
).toThrow('duplicate_runtime_tab_id')
expect(
(runtime as unknown as { authoritativeWindowId: number | null }).authoritativeWindowId
).toBe(null)
expect(() => runtime.syncWindowGraph(1, graph([tab('tab-valid', WORKTREE_A)]))).not.toThrow()
})
it('rejects duplicate tab ids across worktrees before replacing the graph', () => {
const runtime = new OrcaRuntimeService()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, graph([tab('tab-unique', WORKTREE_A)]))
expect(() =>
runtime.syncWindowGraph(
1,
graph([tab('tab-duplicate', WORKTREE_A), tab('tab-duplicate', WORKTREE_B)])
)
).toThrow('duplicate_runtime_tab_id')
expect([...(runtime as unknown as { tabs: Map<string, unknown> }).tabs.keys()]).toEqual([
'tab-unique'
])
})
it('accepts distinct tab ids from different worktrees', () => {
const runtime = new OrcaRuntimeService()
runtime.attachWindow(1)
expect(() =>
runtime.syncWindowGraph(1, graph([tab('tab-a', WORKTREE_A), tab('tab-b', WORKTREE_B)]))
).not.toThrow()
expect([...(runtime as unknown as { tabs: Map<string, unknown> }).tabs.keys()]).toEqual([
'tab-a',
'tab-b'
])
})
})
@@ -0,0 +1,304 @@
import { vi, type Mock } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { RuntimeTerminalListResult } from '../../shared/runtime-types'
import { OrcaRuntimeService } from './orca-runtime'
import {
CANARY_INCARNATION_ID,
CANARY_LEAF_ID,
CANARY_PTY_ID,
CANARY_TAB_ID,
INCARNATION_ID,
LEAF_ID,
PTY_ID,
REPO_ID,
RUNTIME_OWNED_PTY_ID,
SIBLING_INCARNATION_ID,
SIBLING_PTY_ID,
STALE_TAB_ID,
TAB_ID,
WORKTREE_ID,
WORKTREE_PATH,
canaryProcess,
makeSession
} from './orca-runtime-terminal-close-continuity-state-fixture'
import { createCloseContinuityGraphFixture } from './orca-runtime-terminal-close-continuity-graph-fixture'
export {
CANARY_INCARNATION_ID,
CANARY_LEAF_ID,
CANARY_PTY_ID,
CANARY_TAB_ID,
INCARNATION_ID,
LEAF_ID,
OTHER_WORKTREE_ID,
PTY_ID,
REPO_ID,
RUNTIME_OWNED_PTY_ID,
SIBLING_INCARNATION_ID,
SIBLING_LEAF_ID,
SIBLING_PTY_ID,
STALE_TAB_ID,
TAB_ID,
WORKTREE_ID,
WORKTREE_PATH,
makeSession
} from './orca-runtime-terminal-close-continuity-state-fixture'
function makeDeferred() {
let resolve!: () => void
const promise = new Promise<void>((settle) => {
resolve = settle
})
return { promise, resolve }
}
export type CloseContinuityHarness = {
runtime: OrcaRuntimeService
acknowledged: ReturnType<typeof makeDeferred>
closeTerminal: Mock<(...args: unknown[]) => unknown>
closeTerminalTab: Mock<(...args: unknown[]) => unknown>
flushOrThrow: Mock<() => void>
kill: Mock<(ptyId: string) => boolean>
stopAndWait: Mock<(ptyId: string, ...args: unknown[]) => Promise<boolean | void>>
syncCanaryGraph: () => void
syncEmptyGraph: () => void
syncFixtureGraph: () => void
syncFixtureTabWithoutLeaf: () => void
syncSplitFixtureGraph: () => void
getSession: () => WorkspaceSessionState
makeSessionUnavailable: () => void
removeVictimFromInventory: () => void
retirePersistedTab: () => void
setCloseTerminalTabAction: (action: () => void | Promise<void>) => void
rejectTerminalTabClose: (error: Error) => void
rejectPersistenceFlush: (error: Error) => void
setVerifiedStopResult: (result: boolean | Error) => void
setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise<void>) => void
replaceIncarnation: (next: string) => void
replacePersistedIncarnation: (next: string) => void
}
function createHarness(
options: {
ptyId?: string
publishMobileSurface?: boolean
registerPtyBacked?: boolean
includeCanary?: boolean
} = {}
): CloseContinuityHarness {
const ptyId = options.ptyId ?? PTY_ID
let session = makeSession(ptyId, options.includeCanary)
let sessionAvailable = true
let incarnationId = INCARNATION_ID
let includeSiblingPty = false
let victimPtyListed = true
let flushError: Error | null = null
const repo = {
id: REPO_ID,
path: WORKTREE_PATH,
displayName: 'close-continuity',
badgeColor: '#000000',
addedAt: 1
}
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => [],
getWorkspaceSession: () => (sessionAvailable ? session : undefined),
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
flushOrThrow: vi.fn(() => {
if (flushError) {
throw flushError
}
})
}
const acknowledged = makeDeferred()
let closeTerminalTabError: Error | null = null
let closeTerminalTabAction: (() => void | Promise<void>) | null = null
const closeTerminal = vi.fn()
const closeTerminalTab = vi.fn(() => {
if (closeTerminalTabError) {
return Promise.reject(closeTerminalTabError)
}
return closeTerminalTabAction ? Promise.resolve(closeTerminalTabAction()) : acknowledged.promise
})
const kill = vi.fn(() => true)
let verifiedStopResult: boolean | Error = false
let stopAndWaitAction: ((stoppingPtyId: string) => void | Promise<void>) | null = null
const stopAndWait = vi.fn(async (stoppingPtyId: string) => {
await stopAndWaitAction?.(stoppingPtyId)
if (verifiedStopResult instanceof Error) {
throw verifiedStopResult
}
return verifiedStopResult
})
const listProcesses = vi.fn(async () => [
...(victimPtyListed
? [
{
id: ptyId,
incarnationId,
cwd: WORKTREE_PATH,
title: 'Fixture shell'
}
]
: []),
...(includeSiblingPty
? [
{
id: SIBLING_PTY_ID,
incarnationId: SIBLING_INCARNATION_ID,
cwd: WORKTREE_PATH,
title: 'Fixture sibling shell'
}
]
: []),
...(options.includeCanary ? [canaryProcess] : [])
])
const runtime = new OrcaRuntimeService(store as never)
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill,
stopAndWait,
listProcesses,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
const graph = createCloseContinuityGraphFixture({
runtime,
ptyId,
publishMobileSurface: options.publishMobileSurface,
includeCanary: options.includeCanary,
getSession: () => session,
setSession: (next) => {
session = next
},
markSiblingPtyIncluded: () => {
includeSiblingPty = true
}
})
if (options.registerPtyBacked) {
runtime.registerPty(ptyId, WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
if (options.includeCanary) {
runtime.registerPty(CANARY_PTY_ID, WORKTREE_ID, null, {
tabId: CANARY_TAB_ID,
leafId: CANARY_LEAF_ID,
incarnationId: CANARY_INCARNATION_ID
})
}
}
graph.syncFixtureGraph()
return {
runtime,
acknowledged,
closeTerminal,
closeTerminalTab,
flushOrThrow: store.flushOrThrow,
kill,
stopAndWait,
...graph,
getSession: () => session,
makeSessionUnavailable: () => {
sessionAvailable = false
},
removeVictimFromInventory: () => {
victimPtyListed = false
},
retirePersistedTab: () => {
const victimPaneKey = makePaneKey(TAB_ID, LEAF_ID)
session = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).filter(
(tab) => tab.id !== TAB_ID
)
},
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabId !== TAB_ID)
),
terminalPtyIncarnationsByPaneKey: Object.fromEntries(
Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(
([paneKey]) => paneKey !== victimPaneKey
)
)
}
},
setCloseTerminalTabAction: (action: () => void | Promise<void>) => {
closeTerminalTabAction = action
},
rejectTerminalTabClose: (error: Error) => {
closeTerminalTabError = error
},
rejectPersistenceFlush: (error: Error) => {
flushError = error
},
setVerifiedStopResult: (result: boolean | Error) => {
verifiedStopResult = result
},
setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise<void>) => {
stopAndWaitAction = action
},
replaceIncarnation: (next: string) => {
incarnationId = next
},
replacePersistedIncarnation: (next: string) => {
session = {
...session,
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, LEAF_ID)]: next
}
}
}
}
}
function createPtyBackedPublishedSurfaceHarness(): CloseContinuityHarness {
const harness = createHarness({
ptyId: RUNTIME_OWNED_PTY_ID,
publishMobileSurface: true,
registerPtyBacked: true
})
harness.syncFixtureTabWithoutLeaf()
return harness
}
async function createStaleTabCloseHarness(
options: { headless?: boolean } = {}
): Promise<CloseContinuityHarness & { terminal: RuntimeTerminalListResult['terminals'][number] }> {
const harness = createPtyBackedPublishedSurfaceHarness()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === RUNTIME_OWNED_PTY_ID
)!
harness.runtime.registerPty(RUNTIME_OWNED_PTY_ID, WORKTREE_ID, null, {
tabId: STALE_TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
harness.setCloseTerminalTabAction(() => {})
if (options.headless) {
harness.syncEmptyGraph()
}
return { ...harness, terminal }
}
export {
makeDeferred,
createHarness,
createPtyBackedPublishedSurfaceHarness,
createStaleTabCloseHarness
}
@@ -0,0 +1,227 @@
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { OrcaRuntimeService } from './orca-runtime'
import {
CANARY_LEAF_ID,
CANARY_TAB_ID,
LEAF_ID,
SIBLING_INCARNATION_ID,
SIBLING_LEAF_ID,
SIBLING_PTY_ID,
TAB_ID,
WORKTREE_ID,
canaryMobileTab,
canarySyncedLeaf,
canarySyncedTab
} from './orca-runtime-terminal-close-continuity-state-fixture'
import { makePaneKey } from '../../shared/stable-pane-id'
export type CloseContinuityGraphOptions = {
ptyId: string
publishMobileSurface?: boolean
includeCanary?: boolean
}
type CloseContinuityGraphFixtureArgs = CloseContinuityGraphOptions & {
runtime: OrcaRuntimeService
getSession: () => WorkspaceSessionState
setSession: (session: WorkspaceSessionState) => void
markSiblingPtyIncluded: () => void
}
export function createCloseContinuityGraphFixture({
runtime,
ptyId,
publishMobileSurface,
includeCanary,
getSession,
setSession,
markSiblingPtyIncluded
}: CloseContinuityGraphFixtureArgs) {
const syncFixtureGraph = () =>
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: { type: 'leaf', leafId: LEAF_ID }
},
...(includeCanary ? [canarySyncedTab] : [])
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 7,
ptyId
},
...(includeCanary ? [canarySyncedLeaf] : [])
],
...(publishMobileSurface
? {
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: `${TAB_ID}::${LEAF_ID}`,
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: `${TAB_ID}::${LEAF_ID}`,
parentTabId: TAB_ID,
leafId: LEAF_ID,
ptyId,
title: 'Fixture shell',
isActive: true
},
...(includeCanary ? [canaryMobileTab] : [])
]
}
]
}
: {})
})
const syncCanaryGraph = () =>
runtime.syncWindowGraph(1, {
tabs: [canarySyncedTab],
leaves: [canarySyncedLeaf],
...(publishMobileSurface
? {
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
activeTabType: 'terminal' as const,
tabs: [{ ...canaryMobileTab, isActive: true }]
}
]
}
: {})
})
const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const syncFixtureTabWithoutLeaf = () =>
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: { type: 'leaf', leafId: LEAF_ID }
},
...(includeCanary ? [canarySyncedTab] : [])
],
leaves: includeCanary ? [canarySyncedLeaf] : []
})
const syncSplitFixtureGraph = () => {
markSiblingPtyIncluded()
const splitLayout = {
root: {
type: 'split' as const,
direction: 'horizontal' as const,
first: { type: 'leaf' as const, leafId: LEAF_ID },
second: { type: 'leaf' as const, leafId: SIBLING_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_ID]: ptyId,
[SIBLING_LEAF_ID]: SIBLING_PTY_ID
}
}
const session = getSession()
setSession({
...session,
terminalLayoutsByTabId: {
[TAB_ID]: splitLayout
},
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, SIBLING_LEAF_ID)]: SIBLING_INCARNATION_ID
}
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: splitLayout.root
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 7,
ptyId
},
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SIBLING_LEAF_ID,
paneRuntimeId: 8,
ptyId: SIBLING_PTY_ID
}
],
...(publishMobileSurface
? {
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity-split',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: `${TAB_ID}::${LEAF_ID}`,
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: `${TAB_ID}::${LEAF_ID}`,
parentTabId: TAB_ID,
leafId: LEAF_ID,
ptyId,
title: 'Fixture shell',
parentLayout: splitLayout,
isActive: true
},
{
type: 'terminal' as const,
id: `${TAB_ID}::${SIBLING_LEAF_ID}`,
parentTabId: TAB_ID,
leafId: SIBLING_LEAF_ID,
ptyId: SIBLING_PTY_ID,
title: 'Fixture sibling shell',
parentLayout: splitLayout,
isActive: false
}
]
}
]
}
: {})
})
}
return {
syncFixtureGraph,
syncCanaryGraph,
syncEmptyGraph,
syncFixtureTabWithoutLeaf,
syncSplitFixtureGraph
}
}
@@ -0,0 +1,108 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
export const REPO_ID = 'repo-close-continuity'
export const WORKTREE_PATH = '/tmp/terminal-close-continuity'
export const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
export const TAB_ID = 'tab-close-continuity'
export const LEAF_ID = '11111111-1111-4111-8111-111111111111'
export const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
export const CANARY_TAB_ID = 'tab-close-continuity-canary'
export const CANARY_LEAF_ID = '55555555-5555-4555-8555-555555555555'
export const PTY_ID = 'pty-close-continuity'
export const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity'
export const SIBLING_PTY_ID = 'pty-close-continuity-sibling'
export const CANARY_PTY_ID = 'pty-close-continuity-canary'
export const STALE_TAB_ID = 'tab-close-continuity-stale'
export const OTHER_WORKTREE_ID = `${REPO_ID}::/tmp/terminal-close-continuity-other`
export const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
export const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444'
export const CANARY_INCARNATION_ID = '66666666-6666-4666-8666-666666666666'
const canarySessionTab = {
id: CANARY_TAB_ID,
ptyId: CANARY_PTY_ID,
worktreeId: WORKTREE_ID,
title: 'Canary shell',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
const canarySessionLayout = {
root: { type: 'leaf' as const, leafId: CANARY_LEAF_ID },
activeLeafId: CANARY_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID }
}
export const canarySyncedTab = {
tabId: CANARY_TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Canary shell',
activeLeafId: CANARY_LEAF_ID,
layout: { type: 'leaf' as const, leafId: CANARY_LEAF_ID }
}
export const canarySyncedLeaf = {
tabId: CANARY_TAB_ID,
worktreeId: WORKTREE_ID,
leafId: CANARY_LEAF_ID,
paneRuntimeId: 9,
ptyId: CANARY_PTY_ID
}
export const canaryMobileTab = {
type: 'terminal' as const,
id: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
parentTabId: CANARY_TAB_ID,
leafId: CANARY_LEAF_ID,
ptyId: CANARY_PTY_ID,
title: 'Canary shell',
isActive: false
}
export const canaryProcess = {
id: CANARY_PTY_ID,
incarnationId: CANARY_INCARNATION_ID,
cwd: WORKTREE_PATH,
title: 'Canary shell'
}
export function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: TAB_ID,
ptyId,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
...(includeCanary ? [canarySessionTab] : [])
]
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
},
...(includeCanary ? { [CANARY_TAB_ID]: canarySessionLayout } : {})
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID,
...(includeCanary
? { [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID }
: {})
}
}
}
@@ -1,423 +1,185 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { OrcaRuntimeService } from './orca-runtime'
import {
CANARY_INCARNATION_ID,
CANARY_LEAF_ID,
CANARY_PTY_ID,
CANARY_TAB_ID,
createHarness,
createPtyBackedPublishedSurfaceHarness,
createStaleTabCloseHarness,
INCARNATION_ID,
LEAF_ID,
OTHER_WORKTREE_ID,
PTY_ID,
RUNTIME_OWNED_PTY_ID,
SIBLING_INCARNATION_ID,
SIBLING_LEAF_ID,
SIBLING_PTY_ID,
STALE_TAB_ID,
TAB_ID,
WORKTREE_ID
} from './orca-runtime-terminal-close-continuity-fixtures'
const REPO_ID = 'repo-close-continuity'
const WORKTREE_PATH = '/tmp/terminal-close-continuity'
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
const TAB_ID = 'tab-close-continuity'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
const CANARY_TAB_ID = 'tab-close-continuity-canary'
const CANARY_LEAF_ID = '55555555-5555-4555-8555-555555555555'
const PTY_ID = 'pty-close-continuity'
const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity'
const SIBLING_PTY_ID = 'pty-close-continuity-sibling'
const CANARY_PTY_ID = 'pty-close-continuity-canary'
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444'
const CANARY_INCARNATION_ID = '66666666-6666-4666-8666-666666666666'
const canarySessionTab = {
id: CANARY_TAB_ID,
ptyId: CANARY_PTY_ID,
worktreeId: WORKTREE_ID,
title: 'Canary shell',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
const canarySessionLayout = {
root: { type: 'leaf' as const, leafId: CANARY_LEAF_ID },
activeLeafId: CANARY_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID }
}
const canarySyncedTab = {
tabId: CANARY_TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Canary shell',
activeLeafId: CANARY_LEAF_ID,
layout: { type: 'leaf' as const, leafId: CANARY_LEAF_ID }
}
const canarySyncedLeaf = {
tabId: CANARY_TAB_ID,
worktreeId: WORKTREE_ID,
leafId: CANARY_LEAF_ID,
paneRuntimeId: 9,
ptyId: CANARY_PTY_ID
}
const canaryMobileTab = {
type: 'terminal' as const,
id: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
parentTabId: CANARY_TAB_ID,
leafId: CANARY_LEAF_ID,
ptyId: CANARY_PTY_ID,
title: 'Canary shell',
isActive: false
}
const canaryProcess = {
id: CANARY_PTY_ID,
incarnationId: CANARY_INCARNATION_ID,
cwd: WORKTREE_PATH,
title: 'Canary shell'
}
describe('terminal close and handle incarnation continuity', () => {
it('delegates a stale spawn-time tab through its current PTY-backed renderer surface', async () => {
const harness = await createStaleTabCloseHarness()
const { terminal } = harness
function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: TAB_ID,
ptyId,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
...(includeCanary ? [canarySessionTab] : [])
]
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
},
...(includeCanary ? { [CANARY_TAB_ID]: canarySessionLayout } : {})
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID,
...(includeCanary
? { [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID }
: {})
}
}
}
function makeDeferred() {
let resolve!: () => void
const promise = new Promise<void>((settle) => {
resolve = settle
})
return { promise, resolve }
}
function createHarness(
options: {
ptyId?: string
publishMobileSurface?: boolean
registerPtyBacked?: boolean
includeCanary?: boolean
} = {}
) {
const ptyId = options.ptyId ?? PTY_ID
let session = makeSession(ptyId, options.includeCanary)
let sessionAvailable = true
let incarnationId = INCARNATION_ID
let includeSiblingPty = false
let victimPtyListed = true
const repo = {
id: REPO_ID,
path: WORKTREE_PATH,
displayName: 'close-continuity',
badgeColor: '#000000',
addedAt: 1
}
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => [],
getWorkspaceSession: () => (sessionAvailable ? session : undefined),
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
flushOrThrow: () => {}
}
const acknowledged = makeDeferred()
let closeTerminalTabError: Error | null = null
let closeTerminalTabAction: (() => void | Promise<void>) | null = null
const closeTerminal = vi.fn()
const closeTerminalTab = vi.fn(() => {
if (closeTerminalTabError) {
return Promise.reject(closeTerminalTabError)
}
return closeTerminalTabAction ? Promise.resolve(closeTerminalTabAction()) : acknowledged.promise
})
const kill = vi.fn(() => true)
let verifiedStopResult: boolean | Error = false
let stopAndWaitAction: ((stoppingPtyId: string) => void | Promise<void>) | null = null
const stopAndWait = vi.fn(async (stoppingPtyId: string) => {
await stopAndWaitAction?.(stoppingPtyId)
if (verifiedStopResult instanceof Error) {
throw verifiedStopResult
}
return verifiedStopResult
})
const listProcesses = vi.fn(async () => [
...(victimPtyListed
? [
{
id: ptyId,
incarnationId,
cwd: WORKTREE_PATH,
title: 'Fixture shell'
}
]
: []),
...(includeSiblingPty
? [
{
id: SIBLING_PTY_ID,
incarnationId: SIBLING_INCARNATION_ID,
cwd: WORKTREE_PATH,
title: 'Fixture sibling shell'
}
]
: []),
...(options.includeCanary ? [canaryProcess] : [])
])
const runtime = new OrcaRuntimeService(store as never)
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill,
stopAndWait,
listProcesses,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
const syncFixtureGraph = () =>
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: { type: 'leaf', leafId: LEAF_ID }
},
...(options.includeCanary ? [canarySyncedTab] : [])
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 7,
ptyId
},
...(options.includeCanary ? [canarySyncedLeaf] : [])
],
...(options.publishMobileSurface
? {
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: `${TAB_ID}::${LEAF_ID}`,
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: `${TAB_ID}::${LEAF_ID}`,
parentTabId: TAB_ID,
leafId: LEAF_ID,
ptyId,
title: 'Fixture shell',
isActive: true
},
...(options.includeCanary ? [canaryMobileTab] : [])
]
}
]
}
: {})
})
const syncCanaryGraph = () =>
runtime.syncWindowGraph(1, {
tabs: [canarySyncedTab],
leaves: [canarySyncedLeaf],
...(options.publishMobileSurface
? {
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
activeTabType: 'terminal' as const,
tabs: [{ ...canaryMobileTab, isActive: true }]
}
]
}
: {})
})
const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const syncFixtureTabWithoutLeaf = () =>
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: { type: 'leaf', leafId: LEAF_ID }
},
...(options.includeCanary ? [canarySyncedTab] : [])
],
leaves: options.includeCanary ? [canarySyncedLeaf] : []
})
const syncSplitFixtureGraph = () => {
includeSiblingPty = true
session = {
...session,
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SIBLING_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_ID]: ptyId,
[SIBLING_LEAF_ID]: SIBLING_PTY_ID
}
}
},
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, SIBLING_LEAF_ID)]: SIBLING_INCARNATION_ID
}
}
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Fixture shell',
activeLeafId: LEAF_ID,
layout: session.terminalLayoutsByTabId[TAB_ID]!.root
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 7,
ptyId
},
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SIBLING_LEAF_ID,
paneRuntimeId: 8,
ptyId: SIBLING_PTY_ID
}
]
})
}
if (options.registerPtyBacked) {
runtime.registerPty(ptyId, WORKTREE_ID, null, {
await expect(harness.runtime.closeTerminalTab(terminal.handle)).resolves.toMatchObject({
handle: terminal.handle,
tabId: TAB_ID,
closeMode: 'tab'
})
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID)
})
it('kills and removes a stale spawn-time tab through its current headless surface', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const { terminal } = harness
const published = vi.fn()
const unsubscribe = harness.runtime.onMobileSessionTabsChanged(published)
await expect(harness.runtime.closeTerminalTab(terminal.handle)).resolves.toMatchObject({
handle: terminal.handle,
tabId: TAB_ID,
closeMode: 'tab'
})
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
harness.kill.mock.invocationCallOrder[0]!
)
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
published.mock.invocationCallOrder[0]!
)
await expect(harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).resolves.toMatchObject(
{
retiredTerminalSurfaces: [
{
parentTabId: TAB_ID,
leafId: LEAF_ID,
ptyId: RUNTIME_OWNED_PTY_ID,
terminal: terminal.handle,
incarnationId: INCARNATION_ID
}
],
tabs: []
}
)
unsubscribe()
})
it('publishes no retirement or absence when the durable headless close fails', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const published = vi.fn()
const unsubscribe = harness.runtime.onMobileSessionTabsChanged(published)
harness.rejectPersistenceFlush(new Error('disk-full'))
await expect(harness.runtime.closeTerminalTab(harness.terminal.handle)).rejects.toThrow(
'disk-full'
)
expect(harness.kill).not.toHaveBeenCalled()
expect(published).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot).toMatchObject({
tabs: [expect.objectContaining({ parentTabId: TAB_ID, leafId: LEAF_ID })]
})
expect(snapshot.retiredTerminalSurfaces).toBeUndefined()
unsubscribe()
})
it('publishes each split leaf retirement with its own terminal handle', async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.syncSplitFixtureGraph()
const before = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
const terminalsByLeafId = new Map(
before.tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.terminal ? [[tab.leafId, tab.terminal] as const] : []
)
)
expect(terminalsByLeafId.size).toBe(2)
harness.syncEmptyGraph()
await expect(
harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_ID, { reason: 'user' })
).resolves.toMatchObject({ closed: true })
const after = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(after.tabs).toEqual([])
expect(after.retiredTerminalSurfaces).toEqual(
expect.arrayContaining([
expect.objectContaining({
leafId: LEAF_ID,
ptyId: PTY_ID,
terminal: terminalsByLeafId.get(LEAF_ID),
incarnationId: INCARNATION_ID
}),
expect.objectContaining({
leafId: SIBLING_LEAF_ID,
ptyId: SIBLING_PTY_ID,
terminal: terminalsByLeafId.get(SIBLING_LEAF_ID),
incarnationId: SIBLING_INCARNATION_ID
})
])
)
})
it.each(['pane', 'tab'] as const)(
'closes an exact hot-state %s whose failed reveal left no persisted row',
async (closeMode) => {
const harness = await createStaleTabCloseHarness({ headless: true })
harness.retirePersistedTab()
await expect(
closeMode === 'tab'
? harness.runtime.closeTerminalTab(harness.terminal.handle)
: harness.runtime.closeTerminal(harness.terminal.handle)
).resolves.toMatchObject({ handle: harness.terminal.handle, tabId: TAB_ID })
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
await expect(
harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
).resolves.toMatchObject({ tabs: [] })
}
)
it('does not let a colliding PTY id close a different persisted incarnation', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
harness.replacePersistedIncarnation(SIBLING_INCARNATION_ID)
const { terminal } = harness
await expect(harness.runtime.closeTerminalTab(terminal.handle)).rejects.toThrow(
'terminal_handle_stale'
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('does not let a PTY handle cross its recorded worktree boundary', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const { terminal } = harness
harness.runtime.registerPty(RUNTIME_OWNED_PTY_ID, OTHER_WORKTREE_ID, null, {
tabId: STALE_TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
if (options.includeCanary) {
runtime.registerPty(CANARY_PTY_ID, WORKTREE_ID, null, {
tabId: CANARY_TAB_ID,
leafId: CANARY_LEAF_ID,
incarnationId: CANARY_INCARNATION_ID
})
}
}
syncFixtureGraph()
return {
runtime,
acknowledged,
closeTerminal,
closeTerminalTab,
kill,
stopAndWait,
syncCanaryGraph,
syncEmptyGraph,
syncFixtureGraph,
syncFixtureTabWithoutLeaf,
syncSplitFixtureGraph,
getSession: () => session,
makeSessionUnavailable: () => {
sessionAvailable = false
},
removeVictimFromInventory: () => {
victimPtyListed = false
},
retirePersistedTab: () => {
const victimPaneKey = makePaneKey(TAB_ID, LEAF_ID)
session = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).filter(
(tab) => tab.id !== TAB_ID
)
},
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabId !== TAB_ID)
),
terminalPtyIncarnationsByPaneKey: Object.fromEntries(
Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(
([paneKey]) => paneKey !== victimPaneKey
)
)
}
},
setCloseTerminalTabAction: (action: () => void | Promise<void>) => {
closeTerminalTabAction = action
},
rejectTerminalTabClose: (error: Error) => {
closeTerminalTabError = error
},
setVerifiedStopResult: (result: boolean | Error) => {
verifiedStopResult = result
},
setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise<void>) => {
stopAndWaitAction = action
},
replaceIncarnation: (next: string) => {
incarnationId = next
}
}
}
function createPtyBackedPublishedSurfaceHarness() {
const harness = createHarness({
ptyId: RUNTIME_OWNED_PTY_ID,
publishMobileSurface: true,
registerPtyBacked: true
await expect(harness.runtime.closeTerminalTab(terminal.handle)).rejects.toThrow(
'terminal_handle_stale'
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
harness.syncFixtureTabWithoutLeaf()
return harness
}
describe('terminal close and handle incarnation continuity', () => {
it('does not acknowledge final-pane close before durable tab retirement', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
const PTY_ID = 'ssh:target@@relay-pty'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-terminal'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } {
const writes: string[] = []
const runtime = new OrcaRuntimeService(null)
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: vi.fn(() => true),
getForegroundProcess: async () => null
})
return { runtime, writes }
}
function syncGraph(runtime: OrcaRuntimeService): void {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 1,
ptyId: PTY_ID
}
]
})
}
function register(runtime: OrcaRuntimeService, incarnationId: string): void {
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId
})
}
describe('runtime terminal handle incarnation fencing', () => {
it('preserves a direct handle while the PTY incarnation is unchanged', async () => {
const { runtime } = makeRuntime()
const handle = runtime.preAllocateHandleForPty(PTY_ID)
register(runtime, 'incarnation-1')
syncGraph(runtime)
register(runtime, 'incarnation-1')
await expect(runtime.readTerminal(handle)).resolves.toMatchObject({
handle,
status: 'running'
})
})
it('treats a null-to-known incarnation as the same un-fenced PTY', async () => {
const { runtime } = makeRuntime()
const handle = runtime.preAllocateHandleForPty(PTY_ID)
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', {
tabId: TAB_ID,
leafId: LEAF_ID
})
syncGraph(runtime)
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: 'incarnation-learned'
})
await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle, status: 'running' })
})
it('invalidates a direct handle when a reused PTY id gets a new incarnation', async () => {
const { runtime, writes } = makeRuntime()
const staleHandle = runtime.preAllocateHandleForPty(PTY_ID)
register(runtime, 'incarnation-old')
syncGraph(runtime)
await expect(runtime.readTerminal(staleHandle)).resolves.toMatchObject({
handle: staleHandle,
status: 'running'
})
register(runtime, 'incarnation-new')
const [replacement] = (await runtime.listTerminals()).terminals
expect(replacement).toMatchObject({
ptyId: PTY_ID,
incarnationId: 'incarnation-new'
})
expect(replacement?.handle).not.toBe(staleHandle)
await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale')
await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow(
'terminal_handle_stale'
)
await expect(
runtime.sendTerminal(replacement!.handle, { text: 'replacement input' })
).resolves.toMatchObject({
accepted: true,
handle: replacement!.handle
})
expect(writes).toEqual(['replacement input'])
})
it('invalidates the predecessor before registration when spawn notification updates incarnation', async () => {
const { runtime } = makeRuntime()
const staleHandle = runtime.preAllocateHandleForPty(PTY_ID)
register(runtime, 'incarnation-old')
syncGraph(runtime)
await expect(runtime.readTerminal(staleHandle)).resolves.toMatchObject({ status: 'running' })
// Local providers notify the runtime as soon as the child starts, before
// the spawn commit calls registerPty with its pane binding.
runtime.onPtySpawned(PTY_ID, 'incarnation-new', { awaitsRegistration: false })
// A provider that asks for the old env handle during its preflight must not
// be able to resurrect that alias after the notification fence.
runtime.registerPreAllocatedHandleForPty(PTY_ID, staleHandle)
register(runtime, 'incarnation-new')
await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale')
})
it('does not let a delayed predecessor handle callback resurrect the replacement alias', async () => {
const { runtime } = makeRuntime()
const staleHandle = runtime.preAllocateHandleForPty(PTY_ID)
register(runtime, 'incarnation-old')
syncGraph(runtime)
runtime.onPtySpawned(PTY_ID, 'incarnation-new', { awaitsRegistration: false })
const replacementHandle = runtime.createPreAllocatedTerminalHandle()
runtime.registerPreAllocatedHandleForPty(PTY_ID, replacementHandle)
register(runtime, 'incarnation-new')
runtime.registerPreAllocatedHandleForPty(PTY_ID, staleHandle)
await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale')
})
it('keeps only the direct replacement alias when its renderer record is stale', async () => {
const { runtime } = makeRuntime()
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: 'incarnation-old'
})
const replacementHandle = runtime.createPreAllocatedTerminalHandle()
runtime.registerPreAllocatedHandleForPty(PTY_ID, replacementHandle)
syncGraph(runtime)
const internals = runtime as unknown as {
handles: Map<string, unknown>
handleByLeafKey: Map<string, string>
}
expect(internals.handles.has(replacementHandle)).toBe(true)
expect(internals.handleByLeafKey.get(`${TAB_ID}::${LEAF_ID}`)).toBe(replacementHandle)
runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: 'incarnation-new',
terminalHandle: replacementHandle
})
expect(internals.handles.has(replacementHandle)).toBe(false)
expect(internals.handleByLeafKey.has(`${TAB_ID}::${LEAF_ID}`)).toBe(false)
await expect(runtime.readTerminal(replacementHandle)).resolves.toMatchObject({
handle: replacementHandle,
status: 'running'
})
})
})
@@ -216,11 +216,26 @@ describe('OrcaRuntimeService terminal surface retirement', () => {
runtime.attachWindow(1)
const staleSnapshot = makeSplitSnapshot()
syncSplit(runtime, staleSnapshot)
const leftBeforeExit = (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs.find(
(tab) => tab.type === 'terminal' && tab.id === 'tab::left'
)
const leftHandle =
leftBeforeExit?.type === 'terminal' && leftBeforeExit.status === 'ready'
? leftBeforeExit.terminal
: null
runtime.onPtyExit('pty-left', 0)
expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({
activeTabId: 'tab::right',
retiredTerminalSurfaces: [
{
parentTabId: 'tab',
leafId: 'left',
ptyId: 'pty-left',
terminal: leftHandle
}
],
tabs: [
{
id: 'tab::right',
@@ -703,4 +718,34 @@ describe('OrcaRuntimeService terminal surface retirement', () => {
unsubscribe()
errorSpy.mockRestore()
})
it('rolls back an in-memory retirement when the durable flush fails', async () => {
let session = makePersistedSplitSession()
const original = structuredClone(session)
const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => {
session = next
})
const runtime = new OrcaRuntimeService(
runtimeStore({
getWorkspaceSession: () => session,
setWorkspaceSession,
flushOrThrow: vi.fn(() => {
throw new Error('disk unavailable')
})
})
)
runtime.attachWindow(1)
syncSplit(runtime)
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-a'
})
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
expect(session).toEqual(original)
expect(setWorkspaceSession).toHaveBeenLastCalledWith(original, LOCAL_EXECUTION_HOST_ID)
expect(setWorkspaceSession).toHaveBeenCalledTimes(2)
})
})
@@ -78,6 +78,7 @@ function createHarness(
deferSpawn?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
graphOnlySource?: boolean
sourceIncarnationId?: string
stopAndWaitResult?: boolean
} = {}
@@ -127,6 +128,7 @@ function createHarness(
})
)
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const rendererSplitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store as never)
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
@@ -145,7 +147,7 @@ function createHarness(
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
runtime.setNotifier({ revealTerminalSession } as never)
runtime.setNotifier({ revealTerminalSession, splitTerminal: rendererSplitTerminal } as never)
runtime.syncWindowGraph(1, {
tabs:
includeSource && options.rendererMounted
@@ -173,17 +175,23 @@ function createHarness(
: [],
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
})
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
})
if (!options.graphOnlySource) {
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
})
}
const internals = runtime as unknown as {
issueHandle: (leaf: unknown) => string
issuePtyHandle: (pty: unknown) => string
leaves: Map<string, unknown>
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
ptysById: Map<string, unknown>
}
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
const handle = options.graphOnlySource
? internals.issueHandle([...internals.leaves.values()][0])
: internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
return {
runtime,
handle,
@@ -192,6 +200,7 @@ function createHarness(
retireRejectedPty,
stopAndWait,
revealTerminalSession,
rendererSplitTerminal,
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
requestedSessionHostIds,
@@ -216,6 +225,64 @@ function createHarness(
}
describe('remote runtime terminal split authority', () => {
it('addresses a graph-backed split by stable leaf identity across a parked remount', async () => {
const harness = createHarness(true, { rendererMounted: true, graphOnlySource: true })
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
const newLeafId = harness.rendererSplitTerminal.mock.calls[0]?.[2]?.newLeafId
expect(newLeafId).toEqual(expect.any(String))
if (typeof newLeafId !== 'string') {
throw new Error('split notifier did not receive a pre-minted leaf id')
}
expect(harness.rendererSplitTerminal).toHaveBeenCalledWith(TAB_ID, 1, {
direction: 'vertical',
command: undefined,
worktreeId: WORKTREE_ID,
sourceLeafId: SOURCE_LEAF_ID,
telemetrySource: undefined,
newLeafId
})
harness.runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Restored terminal',
activeLeafId: SOURCE_LEAF_ID,
layout: {
type: 'split',
direction: 'vertical',
ratio: 0.5,
first: { type: 'leaf', leafId: SOURCE_LEAF_ID },
second: { type: 'leaf', leafId: newLeafId }
}
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SOURCE_LEAF_ID,
paneRuntimeId: 7,
ptyId: SOURCE_PTY_ID
},
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: newLeafId,
paneRuntimeId: 8,
ptyId: SPLIT_PTY_ID
}
]
})
await expect(split).resolves.toMatchObject({
tabId: TAB_ID,
handle: expect.stringMatching(/^term_/)
})
})
it('splits a persisted tab without consulting an unmounted host renderer', async () => {
const harness = createHarness()
+96 -2
View File
@@ -1656,6 +1656,7 @@ function makeRuntimeStoreWithWorkspaceSession(
runtimeStore: typeof store & {
getWorkspaceSession: (hostId?: string) => WorkspaceSessionState
setWorkspaceSession: ReturnType<typeof vi.fn>
flushOrThrow: ReturnType<typeof vi.fn>
persistPtyBinding: ReturnType<typeof vi.fn>
}
getSession: () => WorkspaceSessionState
@@ -1670,6 +1671,9 @@ function makeRuntimeStoreWithWorkspaceSession(
getWorkspaceSession: (hostId?: string) =>
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
setWorkspaceSession: vi.fn(setSession),
// Headless close is a durable transaction; keep the in-memory fixture's
// persistence contract equivalent to the production store.
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn(
(args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => {
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
@@ -21421,6 +21425,94 @@ describe('OrcaRuntimeService', () => {
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID] ?? 0).toBe(0)
})
it('does not acknowledge another adoption until the staged owner is durable', async () => {
const session = {
...getDefaultWorkspaceSession(),
activeRepoId: TEST_REPO_ID,
activeWorktreeId: TEST_WORKTREE_ID,
tabsByWorktree: { [TEST_WORKTREE_ID]: [] }
}
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
const firstWrite = deferred<void>()
const firstWriteStarted = deferred<void>()
let flushCount = 0
const flushPendingOrThrowAsync = vi.fn(() => {
flushCount += 1
if (flushCount === 1) {
firstWriteStarted.resolve()
return firstWrite.promise
}
return Promise.resolve()
})
const listProcesses = vi.fn(async () => [
{
id: 'pty-serialized-adoption',
incarnationId: 'inc-serialized-adoption',
terminalHandle: 'term_serialized_adoption',
title: 'Serialized adoption',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
wslDistro: null
}
])
const runtime = new OrcaRuntimeService({
...runtimeStore,
flushPendingOrThrowAsync
} as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: async () => null,
listProcesses
})
const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
const request = {
worktree: `id:${TEST_WORKTREE_ID}`,
expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0,
claims: [
{
terminal: 'term_serialized_adoption',
ptyId: 'pty-serialized-adoption',
incarnationId: 'inc-serialized-adoption',
tabId: 'tab-serialized-adoption',
leafId: HEADLESS_LEAF_ID
}
]
}
const first = runtime.adoptTerminalOrphans(request)
await firstWriteStarted.promise
const inventoryCountWhileStaged = listProcesses.mock.calls.length
let secondSettled = false
const second = runtime.adoptTerminalOrphans(request)
void second.then(
() => {
secondSettled = true
},
() => {
secondSettled = true
}
)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(secondSettled).toBe(false)
expect(listProcesses).toHaveBeenCalledTimes(inventoryCountWhileStaged)
expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce()
const firstFailure = expect(first).rejects.toThrow('disk unavailable')
firstWrite.reject(new Error('disk unavailable'))
await firstFailure
const adopted = await second
expect(adopted.adopted).toBe(true)
expect(listProcesses).toHaveBeenCalledTimes(inventoryCountWhileStaged + 1)
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2)
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'tab-serialized-adoption' })
])
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1)
})
function publishLegacyWorkerReveal(
runtime: OrcaRuntimeService,
identity: { worktreeId: string; tabId: string; leafId: string; ptyId: string },
@@ -29841,6 +29933,7 @@ describe('OrcaRuntimeService', () => {
)
const runtime = new OrcaRuntimeService({
...store,
flushOrThrow: vi.fn(),
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined),
getWorkspaceSession
@@ -29896,7 +29989,8 @@ describe('OrcaRuntimeService', () => {
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined),
getWorkspaceSession: (hostId?: string | null) =>
hostId === 'ssh:ssh-1' ? sshSession : localSession,
setWorkspaceSession
setWorkspaceSession,
flushOrThrow: vi.fn()
} as never)
runtime.setPtyController({
write: () => true,
@@ -30683,7 +30777,7 @@ describe('OrcaRuntimeService', () => {
const acknowledged = makeDeferred()
const closeTerminalTab = vi.fn(() => acknowledged.promise)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never)
runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
File diff suppressed because it is too large Load Diff
@@ -183,10 +183,24 @@ describe('quarter-circle title send authorization (STA-4028)', () => {
launchIncarnationId: 'initial-incarnation',
launchToken: expect.any(String)
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
await expect(runtime.getTerminalAgentStatus(handle)).rejects.toThrow('terminal_handle_stale')
runtime.registerPty(PTY_ID, WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: 'replacement-incarnation'
})
const replacementHandle = runtime.getTerminalHandleForPaneKey(`${TAB_ID}:${LEAF_ID}`)
if (replacementHandle === null) {
throw new Error('replacement terminal handle was not registered')
}
expect(replacementHandle).not.toBe(handle)
await expect(runtime.getTerminalAgentStatus(replacementHandle)).resolves.toMatchObject({
isRunningAgent: false
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent')
await expect(guardedSendResult(runtime, replacementHandle)).resolves.toBe(
'terminal_guard_no_agent'
)
})
it('authorizes a guarded send when the busy title itself names the agent', async () => {
@@ -9,11 +9,7 @@ export function installMultiplexCleanup(
): asserts build is TerminalMultiplexCleanupStage {
const state = build as TerminalMultiplexConnection
const { runtime, streams, pendingPtyWaitControllers, emit, signal } = state
state.detachStream = (
streamId: number,
emitEnd: boolean,
releaseRemoteDesktopDriver = true
): void => {
state.detachStream = (streamId: number, endVerdict, releaseRemoteDesktopDriver = true): void => {
const stream = streams.get(streamId)
if (!stream) {
return
@@ -54,8 +50,8 @@ export function installMultiplexCleanup(
// Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor.
runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey)
}
if (emitEnd) {
emit({ type: 'end', streamId })
if (endVerdict) {
emit({ type: 'end', streamId, verdict: endVerdict })
}
}
state.cancelPendingPtyWaits = (streamId: number): void => {
@@ -88,7 +84,7 @@ export function installMultiplexCleanup(
keys.push(stream.remoteDesktopSubscriptionKey)
remoteDesktopKeysByPty.set(stream.ptyId, keys)
}
state.detachStream(streamId, false, false)
state.detachStream(streamId, null, false)
}
// Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once.
for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) {
@@ -11,6 +11,7 @@ import type {
} from './stream-schemas'
import type { TerminalMultiplexStream } from './terminal-stream-types'
import type { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry'
import type { TerminalStreamEndVerdict } from '../../../../../shared/terminal-stream-end-verdict'
export type MultiplexSubscribeRequest = z.infer<typeof TerminalMultiplexSubscribeFrame>
export type MultiplexSnapshotRequest = z.infer<typeof TerminalMultiplexSnapshotRequestFrame>
@@ -72,7 +73,11 @@ export type TerminalMultiplexFlowControl = {
}
export type TerminalMultiplexCleanup = {
detachStream: (streamId: number, emitEnd: boolean, releaseRemoteDesktopDriver?: boolean) => void
detachStream: (
streamId: number,
endVerdict: TerminalStreamEndVerdict | null,
releaseRemoteDesktopDriver?: boolean
) => void
cancelPendingPtyWaits: (streamId: number) => void
cancelAllPendingPtyWaits: () => void
closeMultiplex: () => void
@@ -140,7 +140,7 @@ export function installMultiplexFlowControl(
stream.streamId,
error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.'
)
state.detachStream(stream.streamId, true)
state.detachStream(stream.streamId, 'unverifiable')
} finally {
if (streams.get(stream.streamId) === stream) {
stream.ackRecoverySnapshotInFlight = false
@@ -108,7 +108,7 @@ export function installMultiplexFrameDelivery(
: undefined
if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') {
if (prepared?.status !== 'capacity') {
state.detachStream(stream.streamId, true)
state.detachStream(stream.streamId, 'unverifiable')
}
return false
}
@@ -124,7 +124,7 @@ export function installMultiplexFrameDelivery(
return false
}
if (admission && !admission.commit()) {
state.detachStream(stream.streamId, true)
state.detachStream(stream.streamId, 'unverifiable')
return false
}
if (stream.ackOutput) {
@@ -126,14 +126,19 @@ export function activateMultiplexStream(
condition: 'exit',
signal: stream.exitWaiterAbort.signal
})
.then(() => {
.then((wait) => {
if (streams.get(request.streamId) === stream) {
state.detachStream(request.streamId, true)
state.detachStream(
request.streamId,
wait.satisfied && wait.condition === 'exit' && wait.status === 'exited'
? 'exited'
: 'unverifiable'
)
}
})
.catch(() => {
if (streams.get(request.streamId) === stream) {
state.detachStream(request.streamId, true)
state.detachStream(request.streamId, 'unverifiable')
}
})
}
@@ -38,7 +38,7 @@ export function installMultiplexSlotFrames(
}
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
state.cancelPendingPtyWaits(stream.streamId)
state.detachStream(stream.streamId, false)
state.detachStream(stream.streamId, null)
return
}
if (frame.opcode === TerminalStreamOpcode.Ack) {
@@ -40,7 +40,7 @@ export function installMultiplexSubscribeFrame(
if (state.streams.get(request.streamId) !== installedStream) {
return
}
state.detachStream(request.streamId, false)
state.detachStream(request.streamId, null)
state.sendStreamError(
request.streamId,
error instanceof Error ? error.message : String(error)
@@ -62,7 +62,7 @@ export function installMultiplexSubscribeFrame(
if (state.streams.get(request.streamId) !== stream) {
return
}
state.detachStream(request.streamId, false)
state.detachStream(request.streamId, null)
state.sendStreamError(
request.streamId,
error instanceof Error ? error.message : String(error)
@@ -23,7 +23,7 @@ function finalizeResolvedMultiplexPty(
return null
}
// Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md).
state.detachStream(request.streamId, false)
state.detachStream(request.streamId, null)
if (state.streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) {
state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
state.emit({ type: 'end', streamId: request.streamId })
@@ -37,7 +37,7 @@ export function resolveMultiplexSubscribePty(
request: MultiplexSubscribeRequest
): string | null | Promise<string | null> {
const { runtime, pendingPtyWaitControllers, registerBinaryStreamHandler, signal, emit } = state
state.detachStream(request.streamId, false)
state.detachStream(request.streamId, null)
state.cancelPendingPtyWaits(request.streamId)
let leaf: { ptyId: string | null } | null
@@ -71,7 +71,7 @@ export function resolveMultiplexSubscribePty(
const unregisterPendingHandler = registerBinaryStreamHandler(request.streamId, (frame) => {
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
state.cancelPendingPtyWaits(request.streamId)
state.detachStream(request.streamId, false)
state.detachStream(request.streamId, null)
}
})
return (async () => {
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
import {
sendDesktopMultiplexSubscribe,
startDesktopMultiplexSubscribe
} from './terminal-multiplex-test-harness'
type ControlledWait = {
promise: Promise<RuntimeTerminalWait>
reject: (error: Error) => void
resolve: (result: RuntimeTerminalWait) => void
}
function createControlledWait(): ControlledWait {
let resolve = (_result: RuntimeTerminalWait): void => {}
let reject = (_error: Error): void => {}
const promise = new Promise<RuntimeTerminalWait>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
async function startSubscribedTerminal(wait: ControlledWait) {
const harness = startDesktopMultiplexSubscribe({
waitForTerminal: vi.fn(() => wait.promise)
})
await vi.waitFor(() =>
expect(harness.messages.some((message) => JSON.parse(message).result?.type === 'ready')).toBe(
true
)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(harness.runtime.waitForTerminal).toHaveBeenCalled())
return harness
}
function endEvents(messages: string[]): unknown[] {
return messages
.map((message) => JSON.parse(message).result)
.filter((result) => result?.type === 'end')
}
describe('terminal multiplex end verdict', () => {
it('reports exited only when the owning runtime completes the exit waiter', async () => {
const wait = createControlledWait()
const harness = await startSubscribedTerminal(wait)
wait.resolve({
handle: 'terminal-1',
condition: 'exit',
satisfied: true,
status: 'exited',
exitCode: 0
})
await vi.waitFor(() =>
expect(endEvents(harness.messages)).toContainEqual({
type: 'end',
streamId: 7,
verdict: 'exited'
})
)
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
await harness.dispatchPromise
})
it('reports unverifiable when the runtime cannot observe the exit waiter', async () => {
const wait = createControlledWait()
const harness = await startSubscribedTerminal(wait)
wait.reject(new Error('stale terminal handle'))
await vi.waitFor(() =>
expect(endEvents(harness.messages)).toContainEqual({
type: 'end',
streamId: 7,
verdict: 'unverifiable'
})
)
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
await harness.dispatchPromise
})
it('reports unverifiable when a disconnected PTY resolves with unknown liveness', async () => {
const wait = createControlledWait()
const harness = await startSubscribedTerminal(wait)
wait.resolve({
handle: 'terminal-1',
condition: 'exit',
satisfied: true,
status: 'unknown',
exitCode: null
})
await vi.waitFor(() =>
expect(endEvents(harness.messages)).toContainEqual({
type: 'end',
streamId: 7,
verdict: 'unverifiable'
})
)
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
await harness.dispatchPromise
})
})
+40 -2
View File
@@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore, makeTerminalTab } from './persistence-test-harness'
import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import { getDefaultPersistedState } from '../shared/constants'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
@@ -68,7 +69,8 @@ function relayReattachBinds(
leafId: args.leafId,
ptyId: args.ptyId,
...(args.incarnationId ? { incarnationId: args.incarnationId } : {}),
mayCreate: false
mayCreate: false,
mayReviveRetiredSurface: false
})
}
@@ -191,6 +193,42 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', ()
expect(tabIds(store)).toEqual([TAB])
})
it('does not clear and rebind a retired surface loaded from an older profile', async () => {
const paneKey = `${TAB}:${TEST_LEAF_1}`
const persisted = getDefaultPersistedState(testState.dir)
persisted.workspaceSession = {
...persisted.workspaceSession,
...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }),
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' },
terminalSurfaceTombstonesByPaneKey: {
[paneKey]: {
worktreeId: WORKTREE,
parentTabId: TAB,
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-1',
retiredAt: 1
}
}
}
writeDataFile(persisted)
const store = await createStore()
expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey]).toBeDefined()
expect(
relayReattachBinds(store, {
tabId: TAB,
leafId: TEST_LEAF_1,
ptyId: 'pty-1',
incarnationId: 'inc-1'
})
).toBe(false)
expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey]).toBeDefined()
expect(
store.getWorkspaceSession().terminalLayoutsByTabId?.[TAB]?.ptyIdsByLeafId?.[TEST_LEAF_1]
).toBe('pty-1')
})
it('refuses to graft a second leaf into a tab the reattach does not already own', async () => {
const store = await createStore()
store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }))
@@ -2,7 +2,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { clientInstances, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness'
import {
clientInstances,
emitSshEvent,
nextSshClientCreation,
resetSshConnectionMocks,
ssh2Mock
} from './ssh-connection-test-harness'
import { createCallbacks, createTarget } from './ssh-connection-test-fixtures'
import { SshConnection } from './ssh-connection'
import { resolveWithSshG } from './ssh-config-parser'
@@ -169,7 +175,12 @@ describe('SshConnection', () => {
expect(retryConfig.agent).toBeUndefined()
expect(retryConfig.password).toBe('password-123')
expect(retryConfig.privateKey).toBeUndefined()
expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', 'example.com')
expect(onCredentialRequest).toHaveBeenCalledWith(
'target-1',
'password',
'example.com',
expect.any(AbortSignal)
)
})
it('retries password auth with the no-agent key config after direct key fallback fails', async () => {
@@ -212,6 +223,165 @@ describe('SshConnection', () => {
}
})
it('answers bounded keyboard-interactive challenges such as Duo 2FA', async () => {
vi.useFakeTimers()
const onCredentialRequest = vi.fn().mockResolvedValueOnce('1').mockResolvedValueOnce('123456')
try {
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))
const clientCreated = nextSshClientCreation()
const connected = conn.connect()
await clientCreated
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'Duo two-factor login',
'Select push or enter a passcode.',
'',
[
{ prompt: 'Option:', echo: false },
{ prompt: 'Passcode:', echo: false }
],
finish
)
for (let turn = 0; turn < 8 && finish.mock.calls.length === 0; turn += 1) {
await Promise.resolve()
}
expect(clientInstances[0].lastConnectConfig).toMatchObject({ tryKeyboard: true })
expect(onCredentialRequest).toHaveBeenNthCalledWith(
1,
'target-1',
'keyboard-interactive',
'Duo two-factor login\nSelect push or enter a passcode.\nOption:',
expect.any(AbortSignal)
)
expect(onCredentialRequest).toHaveBeenNthCalledWith(
2,
'target-1',
'keyboard-interactive',
'Duo two-factor login\nSelect push or enter a passcode.\nPasscode:',
expect.any(AbortSignal)
)
expect(finish).toHaveBeenCalledWith(['1', '123456'])
await vi.advanceTimersByTimeAsync(1)
await connected
} finally {
vi.useRealTimers()
}
})
it('rearms the handshake budget for each slow prompt in one keyboard-interactive round', async () => {
vi.useFakeTimers()
ssh2Mock.connectBehavior = 'pending'
const firstResponse = Promise.withResolvers<string | null>()
const secondResponse = Promise.withResolvers<string | null>()
const onCredentialRequest = vi
.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockImplementationOnce(() => secondResponse.promise)
try {
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))
const clientCreated = nextSshClientCreation()
const connected = conn.connect()
let settled = false
void connected.then(
() => {
settled = true
},
() => {
settled = true
}
)
await clientCreated
await vi.advanceTimersByTimeAsync(1)
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'Duo two-factor login',
'Complete both checks.',
'',
[
{ prompt: 'Option:', echo: false },
{ prompt: 'Passcode:', echo: false }
],
finish
)
await vi.advanceTimersByTimeAsync(100_000)
firstResponse.resolve('1')
for (let turn = 0; turn < 4 && onCredentialRequest.mock.calls.length < 2; turn += 1) {
await Promise.resolve()
}
expect(onCredentialRequest).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(30_000)
expect(settled).toBe(false)
expect(finish).not.toHaveBeenCalled()
secondResponse.resolve('123456')
for (let turn = 0; turn < 4 && finish.mock.calls.length === 0; turn += 1) {
await Promise.resolve()
}
expect(finish).toHaveBeenCalledWith(['1', '123456'])
emitSshEvent('ready')
await connected
} finally {
vi.useRealTimers()
}
})
it('aborts an in-flight keyboard challenge when the connection is disconnected', async () => {
vi.useFakeTimers()
ssh2Mock.connectBehavior = 'pending'
let credentialSignal: AbortSignal | undefined
const onCredentialRequest = vi.fn(
(_targetId: string, _kind: string, _detail: string, signal?: AbortSignal) => {
credentialSignal = signal
const response = Promise.withResolvers<string | null>()
if (signal?.aborted) {
response.resolve(null)
} else {
signal?.addEventListener('abort', () => response.resolve(null), { once: true })
}
return response.promise
}
)
try {
const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest }))
const clientCreated = nextSshClientCreation()
const connected = conn.connect()
const connectionResult = connected.then(
() => null,
(error: unknown) => error
)
await clientCreated
await vi.advanceTimersByTimeAsync(1)
const finish = vi.fn()
emitSshEvent(
'keyboard-interactive',
'Duo two-factor login',
'Approve the push.',
'',
[{ prompt: 'Response:', echo: false }],
finish
)
await Promise.resolve()
expect(credentialSignal?.aborted).toBe(false)
await conn.disconnect()
expect(credentialSignal?.aborted).toBe(true)
await expect(connectionResult).resolves.toBeInstanceOf(Error)
for (let turn = 0; turn < 4 && finish.mock.calls.length === 0; turn += 1) {
await Promise.resolve()
}
expect(finish).toHaveBeenCalledWith([])
} finally {
vi.useRealTimers()
}
})
it('does not prompt twice when post-agent private key passphrase is cancelled', async () => {
vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock')
const tempDir = mkdtempSync(join(tmpdir(), 'orca-ssh-key-'))
@@ -231,7 +401,12 @@ describe('SshConnection', () => {
await expect(conn.connect()).rejects.toThrow('Encrypted private OpenSSH key detected')
expect(onCredentialRequest).toHaveBeenCalledTimes(1)
expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'passphrase', keyPath)
expect(onCredentialRequest).toHaveBeenCalledWith(
'target-1',
'passphrase',
keyPath,
expect.any(AbortSignal)
)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
@@ -200,7 +200,12 @@ describe('SshConnection', () => {
'echo ORCA-SYSTEM-SSH-OK',
expect.objectContaining({ wrapCommand: false })
)
expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', expect.any(String))
expect(onCredentialRequest).toHaveBeenCalledWith(
'target-1',
'password',
'example.com',
expect.any(AbortSignal)
)
})
it('tries the GSSAPI probe before prompting for an encrypted key passphrase', async () => {
@@ -32,6 +32,7 @@ let presentedHostKey: Buffer
let hostKeyAccepted: boolean | undefined
vi.mock('ssh2', () => {
const utils = { parseKey: vi.fn(() => new Error('parse failed')) }
class MockSshClient {
setNoDelay = vi.fn()
_sock: Socket | undefined = new Socket()
@@ -69,7 +70,8 @@ vi.mock('ssh2', () => {
return {
Client: MockSshClient,
BaseAgent: MockBaseAgent,
default: { Client: MockSshClient, BaseAgent: MockBaseAgent }
utils,
default: { Client: MockSshClient, BaseAgent: MockBaseAgent, utils }
}
})
+228
View File
@@ -0,0 +1,228 @@
import { Socket } from 'node:net'
import { vi } from 'vitest'
import type { Mock } from 'vitest'
export type MockSshClient = {
setNoDelay: ReturnType<typeof vi.fn>
_sock: Socket | undefined
lastExecCommand?: string
lastConnectConfig?: unknown
on: (event: string, handler: (...args: unknown[]) => void) => void
off: (event: string, handler: (...args: unknown[]) => void) => void
connect: (config?: unknown) => void
destroy: () => void
emit: (event: string, ...args: unknown[]) => void
clearPendingTimers: () => void
exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void
sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void
}
export type Ssh2ModuleMock = {
BaseAgent: new () => object
Client: new () => MockSshClient
createAgent: Mock<(...args: unknown[]) => unknown>
utils: { parseKey: Mock<(...args: unknown[]) => unknown> }
}
// Read-only from tests: live ESM bindings so importers observe the mock's writes.
export let eventHandlers = new Map<string, Set<(...args: unknown[]) => void>>()
export let clientInstances: MockSshClient[] = []
export let connectAttempts = 0
export let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
export let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
/** Lets a test present a real key blob instead of the placeholder. */
export const VALID_ED25519_HOST_KEY = Buffer.from(
'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
'base64'
)
// Knobs tests assign to; grouped because imported bindings cannot be reassigned.
export const ssh2Mock = {
presentedHostKey: undefined as Buffer | undefined,
/** What the verifier decided about the presented key on the most recent connect. */
lastHostKeyAccepted: undefined as boolean | undefined,
connectBehavior: 'ready' as 'ready' | 'error' | 'pending',
connectErrorMessage: '',
connectErrorCode: '',
destroyErrorMessage: '',
connectSequence: [] as ('ready' | Error)[],
execBehavior: 'callback' as 'callback' | 'pending',
sftpBehavior: 'callback' as 'callback' | 'pending',
notifyClientCreated: undefined as (() => void) | undefined
}
export const emitSshEvent = (event: string, ...args: unknown[]): void =>
clientInstances.at(-1)?.emit(event, ...args)
export function createSsh2Module(): Ssh2ModuleMock {
class MockBaseAgent {}
class MockSshClient {
setNoDelay = vi.fn()
// Why: production code reads `client._sock` and checks `instanceof net.Socket`
// to decide which log line to emit. A real Socket instance lets the test
// exercise the "enabled" branch instead of the "skipped (proxy socket)" branch.
_sock: Socket | undefined = new Socket()
lastExecCommand?: string
lastConnectConfig?: unknown
private handlers = new Map<string, Set<(...args: unknown[]) => void>>()
private connectTimer: ReturnType<typeof setTimeout> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
constructor() {
clientInstances.push(this)
eventHandlers = this.handlers
ssh2Mock.notifyClientCreated?.()
ssh2Mock.notifyClientCreated = undefined
}
on(event: string, handler: (...args: unknown[]) => void) {
const handlers = this.handlers.get(event) ?? new Set<(...args: unknown[]) => void>()
handlers.add(handler)
this.handlers.set(event, handlers)
}
off(event: string, handler: (...args: unknown[]) => void) {
const handlers = this.handlers.get(event)
handlers?.delete(handler)
if (handlers?.size === 0) {
this.handlers.delete(event)
}
}
emit(event: string, ...args: unknown[]) {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args)
}
}
clearPendingTimers() {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
}
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
}
this.connectTimer = this.handshakeTimer = null
}
connect(config?: unknown) {
connectAttempts += 1
this.lastConnectConfig = config
// Why the callback form: ssh2 calls hostVerifier(key, verify) and only accepts synchronously
// when the return is not undefined. A mock that passed one argument and ignored the result
// would pass against a verifier that never decides — which is the regression host key
// verification exists to prevent.
const hostVerifier = (
config as
| { hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined }
| undefined
)?.hostVerifier
const presentedHostKey = ssh2Mock.presentedHostKey ?? VALID_ED25519_HOST_KEY
ssh2Mock.lastHostKeyAccepted = undefined
hostVerifier?.(presentedHostKey, (ok) => {
ssh2Mock.lastHostKeyAccepted = ok
})
if (ssh2Mock.lastHostKeyAccepted === false) {
// ssh2 aborts the handshake when the verifier denies; a mock that carried on to 'ready'
// would let a rejected host key look like a successful connect.
this.connectTimer = setTimeout(() => {
this.connectTimer = null
this.emit('error', new Error('All configured authentication methods failed'))
}, 0)
return
}
this.connectTimer = setTimeout(() => {
this.connectTimer = null
const next = ssh2Mock.connectSequence.shift()
if (next instanceof Error) {
this.emit('error', next)
return
}
if (next === 'ready') {
this.emit('ready')
return
}
if (ssh2Mock.connectBehavior === 'pending') {
const configValue = this.lastConnectConfig
const readyTimeout =
configValue &&
typeof configValue === 'object' &&
'readyTimeout' in configValue &&
typeof configValue.readyTimeout === 'number'
? configValue.readyTimeout
: undefined
if (readyTimeout && readyTimeout > 0) {
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
this.emit('error', new Error('Timed out while waiting for handshake'))
}, readyTimeout)
}
return
}
if (ssh2Mock.connectBehavior === 'error') {
const err = new Error(ssh2Mock.connectErrorMessage) as NodeJS.ErrnoException
if (ssh2Mock.connectErrorCode) {
err.code = ssh2Mock.connectErrorCode
}
this.emit('error', err)
} else {
this.emit('ready')
}
}, 0)
}
end() {
this.clearPendingTimers()
}
destroy() {
this.clearPendingTimers()
if (!ssh2Mock.destroyErrorMessage) {
this.emit('close')
return
}
if (this.handlers.has('error')) {
this.emit('error', new Error(ssh2Mock.destroyErrorMessage))
return
}
throw new Error(ssh2Mock.destroyErrorMessage)
}
exec(cmd: string, cb: (err: Error | undefined, channel: unknown) => void) {
this.lastExecCommand = cmd
if (ssh2Mock.execBehavior === 'pending') {
pendingExecCallback = cb
return
}
cb(undefined, { close: vi.fn() })
}
sftp(cb: (err: Error | undefined, channel: unknown) => void) {
if (ssh2Mock.sftpBehavior === 'pending') {
pendingSftpCallback = cb
return
}
cb(undefined, { end: vi.fn() })
}
}
return {
BaseAgent: MockBaseAgent,
Client: MockSshClient,
createAgent: vi.fn(),
utils: {
parseKey: vi.fn()
}
}
}
export function resetSsh2ClientState(): void {
for (const client of clientInstances) {
client.clearPendingTimers()
}
eventHandlers = new Map()
connectAttempts = 0
pendingExecCallback = null
pendingSftpCallback = null
ssh2Mock.connectBehavior = 'ready'
ssh2Mock.connectErrorMessage = ''
ssh2Mock.connectErrorCode = ''
ssh2Mock.destroyErrorMessage = ''
ssh2Mock.connectSequence = []
ssh2Mock.execBehavior = 'callback'
ssh2Mock.sftpBehavior = 'callback'
ssh2Mock.notifyClientCreated = undefined
ssh2Mock.presentedHostKey = undefined
ssh2Mock.lastHostKeyAccepted = undefined
clientInstances = []
}
+14 -181
View File
@@ -1,28 +1,24 @@
import { Socket } from 'node:net'
import { vi } from 'vitest'
import { createSystemCommandChannel, createSystemSshProcess } from './ssh-connection-test-fixtures'
import type { Mock } from 'vitest'
import type { SshConnection } from './ssh-connection'
import type { MockSystemCommandChannel, MockSystemSshProcess } from './ssh-connection-test-fixtures'
import type { SshResolvedConfig } from './ssh-config-parser'
import type { SystemSshBuildArgsOptions } from './system-ssh-args'
import type { SshTarget } from '../../shared/ssh-types'
export type MockSshClient = {
setNoDelay: ReturnType<typeof vi.fn>
_sock: Socket | undefined
lastExecCommand?: string
lastConnectConfig?: unknown
exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void
sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void
}
export type Ssh2ModuleMock = {
BaseAgent: new () => object
Client: new () => MockSshClient
createAgent: Mock<(...args: unknown[]) => unknown>
utils: { parseKey: Mock<(...args: unknown[]) => unknown> }
}
import { resetSsh2ClientState, ssh2Mock } from './ssh-connection-test-client'
export {
clientInstances,
connectAttempts,
createSsh2Module,
emitSshEvent,
eventHandlers,
pendingExecCallback,
pendingSftpCallback,
resetSsh2ClientState,
ssh2Mock,
VALID_ED25519_HOST_KEY
} from './ssh-connection-test-client'
export type { MockSshClient, Ssh2ModuleMock } from './ssh-connection-test-client'
export type SystemSshBinaryModuleMock = { findSystemSsh: typeof findSystemSshMock }
@@ -43,34 +39,6 @@ export type ControlSocketModuleMock = {
export type SshConfigParserModuleMock = { resolveWithSshG: typeof resolveWithSshGMock }
// Read-only from tests: live ESM bindings so importers observe the mock's writes.
export let eventHandlers = new Map<string, Set<(...args: unknown[]) => void>>()
export let clientInstances: MockSshClient[] = []
export let connectAttempts = 0
export let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
export let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
/** Lets a test present a real key blob instead of the placeholder. */
export const VALID_ED25519_HOST_KEY = Buffer.from(
'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
'base64'
)
// Knobs tests assign to; grouped because imported bindings cannot be reassigned.
export const ssh2Mock = {
presentedHostKey: undefined as Buffer | undefined,
/** What the verifier decided about the presented key on the most recent connect. */
lastHostKeyAccepted: undefined as boolean | undefined,
connectBehavior: 'ready' as 'ready' | 'error',
connectErrorMessage: '',
connectErrorCode: '',
destroyErrorMessage: '',
connectSequence: [] as ('ready' | Error)[],
execBehavior: 'callback' as 'callback' | 'pending',
sftpBehavior: 'callback' as 'callback' | 'pending',
notifyClientCreated: undefined as (() => void) | undefined
}
export const findSystemSshMock = vi.fn<() => string | null>()
export const getOrcaControlSocketPathMock =
vi.fn<(target: SshTarget, options?: SystemSshBuildArgsOptions) => string | null>()
@@ -88,12 +56,6 @@ export const resolveWithSshGMock = vi
.fn<(...args: unknown[]) => Promise<SshResolvedConfig | null>>()
.mockResolvedValue(null)
export function emitSshEvent(event: string, ...args: unknown[]): void {
for (const handler of eventHandlers?.get(event) ?? []) {
handler(...args)
}
}
export function nextSshClientCreation(): Promise<void> {
return new Promise((resolve) => {
ssh2Mock.notifyClientCreated = resolve
@@ -115,117 +77,6 @@ export async function advanceToNextSshClient(delayMs: number): Promise<void> {
await vi.advanceTimersByTimeAsync(1)
}
export function createSsh2Module(): Ssh2ModuleMock {
class MockBaseAgent {}
class MockSshClient {
setNoDelay = vi.fn()
// Why: production code reads `client._sock` and checks `instanceof net.Socket`
// to decide which log line to emit. A real Socket instance lets the test
// exercise the "enabled" branch instead of the "skipped (proxy socket)" branch.
_sock: Socket | undefined = new Socket()
lastExecCommand?: string
lastConnectConfig?: unknown
constructor() {
clientInstances.push(this)
ssh2Mock.notifyClientCreated?.()
ssh2Mock.notifyClientCreated = undefined
}
on(event: string, handler: (...args: unknown[]) => void) {
const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>()
handlers.add(handler)
eventHandlers?.set(event, handlers)
}
off(event: string, handler: (...args: unknown[]) => void) {
const handlers = eventHandlers?.get(event)
handlers?.delete(handler)
if (handlers?.size === 0) {
eventHandlers.delete(event)
}
}
connect(config?: unknown) {
connectAttempts += 1
this.lastConnectConfig = config
// Why the callback form: ssh2 calls hostVerifier(key, verify) and only accepts synchronously
// when the return is not undefined. A mock that passed one argument and ignored the result
// would pass against a verifier that never decides — which is the regression host key
// verification exists to prevent.
const hostVerifier = (
config as
| { hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined }
| undefined
)?.hostVerifier
const presentedHostKey = ssh2Mock.presentedHostKey ?? VALID_ED25519_HOST_KEY
ssh2Mock.lastHostKeyAccepted = undefined
hostVerifier?.(presentedHostKey, (ok) => {
ssh2Mock.lastHostKeyAccepted = ok
})
if (ssh2Mock.lastHostKeyAccepted === false) {
// ssh2 aborts the handshake when the verifier denies; a mock that carried on to 'ready'
// would let a rejected host key look like a successful connect.
setTimeout(
() => emitSshEvent('error', new Error('All configured authentication methods failed')),
0
)
return
}
setTimeout(() => {
const next = ssh2Mock.connectSequence.shift()
if (next instanceof Error) {
emitSshEvent('error', next)
return
}
if (next === 'ready') {
emitSshEvent('ready')
return
}
if (ssh2Mock.connectBehavior === 'error') {
const err = new Error(ssh2Mock.connectErrorMessage) as NodeJS.ErrnoException
if (ssh2Mock.connectErrorCode) {
err.code = ssh2Mock.connectErrorCode
}
emitSshEvent('error', err)
} else {
emitSshEvent('ready')
}
}, 0)
}
end() {}
destroy() {
if (!ssh2Mock.destroyErrorMessage) {
return
}
if (eventHandlers?.has('error')) {
emitSshEvent('error', new Error(ssh2Mock.destroyErrorMessage))
return
}
throw new Error(ssh2Mock.destroyErrorMessage)
}
exec(cmd: string, cb: (err: Error | undefined, channel: unknown) => void) {
this.lastExecCommand = cmd
if (ssh2Mock.execBehavior === 'pending') {
pendingExecCallback = cb
return
}
cb(undefined, { close: vi.fn() })
}
sftp(cb: (err: Error | undefined, channel: unknown) => void) {
if (ssh2Mock.sftpBehavior === 'pending') {
pendingSftpCallback = cb
return
}
cb(undefined, { end: vi.fn() })
}
}
return {
BaseAgent: MockBaseAgent,
Client: MockSshClient,
createAgent: vi.fn(),
utils: {
parseKey: vi.fn()
}
}
}
// Why: security-key transport selection scans the real ~/.ssh defaults, so a developer's own
// FIDO2 key would otherwise decide which transport these tests take.
export function createSystemSshBinaryModule(): SystemSshBinaryModuleMock {
@@ -253,26 +104,8 @@ export function createSshConfigParserModule(): SshConfigParserModuleMock {
return { resolveWithSshG: resolveWithSshGMock }
}
export function resetSsh2ClientState(): void {
eventHandlers = new Map()
ssh2Mock.connectBehavior = 'ready'
ssh2Mock.connectErrorMessage = ''
ssh2Mock.connectSequence = []
clientInstances = []
}
export function resetSshConnectionMocks(): void {
resetSsh2ClientState()
ssh2Mock.connectErrorCode = ''
ssh2Mock.destroyErrorMessage = ''
connectAttempts = 0
ssh2Mock.execBehavior = 'callback'
pendingExecCallback = null
ssh2Mock.sftpBehavior = 'callback'
pendingSftpCallback = null
ssh2Mock.notifyClientCreated = undefined
ssh2Mock.presentedHostKey = undefined
ssh2Mock.lastHostKeyAccepted = undefined
getOrcaControlSocketPathMock.mockReset()
getOrcaControlSocketPathMock.mockReturnValue(null)
removeControlSocketPathMock.mockReset()
@@ -110,6 +110,15 @@ describe('isTransientError', () => {
expect(isTransientError(new Error('read ECONNRESET'))).toBe(true)
})
it('returns true for the bounded SSH authentication watchdog', () => {
const timeout = Object.assign(new Error('Timed out while waiting for SSH authentication'), {
level: 'client-timeout'
})
expect(isTransientError(timeout)).toBe(true)
expect(isTransientError(new Error('Timed out while waiting for SSH authentication'))).toBe(true)
})
it('returns false for auth errors', () => {
expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false)
})
+19 -6
View File
@@ -13,14 +13,15 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args'
export { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution'
export type SshCredentialKind = 'passphrase' | 'password'
export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactive'
export type SshConnectionCallbacks = {
onStateChange: (targetId: string, state: SshConnectionState) => void
onCredentialRequest?: (
targetId: string,
kind: SshCredentialKind,
detail: string
detail: string,
signal?: AbortSignal
) => Promise<string | null>
}
@@ -33,6 +34,7 @@ export const INITIAL_RETRY_ATTEMPTS = 5
export const INITIAL_RETRY_DELAY_MS = 2000
export const RECONNECT_BACKOFF_MS = [1000, 2000, 5000, 5000, 10000, 10000, 10000, 30000, 30000]
export const CONNECT_TIMEOUT_MS = 30_000
export const SSH_CREDENTIAL_TIMEOUT_MS = 120_000
const TRANSIENT_ERROR_CODES = new Set([
'ETIMEDOUT',
@@ -43,6 +45,10 @@ const TRANSIENT_ERROR_CODES = new Set([
'EAI_AGAIN'
])
function sshErrorLevel(err: Error): unknown {
return 'level' in err ? err.level : undefined
}
export function isAuthError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
@@ -52,16 +58,22 @@ export function isAuthError(err: Error): boolean {
/permission denied(?:, please try again\.?| \([^)]*(?:publickey|password|keyboard-interactive|gssapi|hostbased)[^)]*\))/.test(
msg
) ||
(err as { level?: string }).level === 'client-authentication'
sshErrorLevel(err) === 'client-authentication'
)
}
export function isAgentFallbackError(err: Error): boolean {
return isAuthError(err) || (err as { level?: string }).level === 'agent'
return isAuthError(err) || sshErrorLevel(err) === 'agent'
}
export function isTransientError(err: Error): boolean {
const code = (err as NodeJS.ErrnoException).code
if (
sshErrorLevel(err) === 'client-timeout' ||
err.message === 'Timed out while waiting for SSH authentication'
) {
return true
}
const code = 'code' in err && typeof err.code === 'string' ? err.code : undefined
if (code && TRANSIENT_ERROR_CODES.has(code)) {
return true
}
@@ -189,7 +201,8 @@ export function buildConnectConfig(
port: effectivePort,
username: effectiveUser,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 15_000
keepaliveInterval: 15_000,
tryKeyboard: true
}
const shouldIncludeAgent = options.includeAgent ?? true
+31 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import {
clientInstances,
createSsh2Module,
eventHandlers,
resetSshConnectionMocks,
VALID_ED25519_HOST_KEY,
@@ -107,6 +108,34 @@ describe('SshConnection', () => {
expect(eventHandlers.has('error')).toBe(true)
})
it('scopes lifecycle events and pending handshake timers to one mock client', async () => {
vi.useFakeTimers()
try {
const { Client } = createSsh2Module()
const first = new Client()
const second = new Client()
const firstClose = vi.fn()
const secondClose = vi.fn()
const firstError = vi.fn()
first.on('close', firstClose)
first.on('error', firstError)
second.on('close', secondClose)
first.emit('close')
expect(firstClose).toHaveBeenCalledOnce()
expect(secondClose).not.toHaveBeenCalled()
ssh2Mock.connectBehavior = 'pending'
first.connect({ readyTimeout: 1_000 })
await vi.advanceTimersByTimeAsync(0)
first.destroy()
await vi.advanceTimersByTimeAsync(1_000)
expect(firstError).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('enables TCP_NODELAY on the new ssh2 client after a reconnect cycle', async () => {
// Why: guards the "Nagle is re-enabled because someone refactored only
// the initial connect path" regression class. attemptConnect bumps
@@ -200,7 +229,7 @@ describe('SshConnection', () => {
)
})
it('keeps disconnected state when ssh2 reports a late startup error', async () => {
it('keeps the cancellation outcome when ssh2 reports a late startup error', async () => {
ssh2Mock.connectBehavior = 'error'
ssh2Mock.connectErrorMessage = 'Connection lost before handshake'
const callbacks = createCallbacks()
@@ -215,7 +244,7 @@ describe('SshConnection', () => {
await conn.disconnect()
await expect(connectResult).resolves.toMatchObject({
message: 'Connection lost before handshake'
message: 'SSH connection attempt was cancelled'
})
expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null })
expect(callbacks.onStateChange).not.toHaveBeenCalledWith(

Some files were not shown because too many files have changed in this diff Show More