From aca05800d9610dad022b348769bfea0f07a1cf28 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:41:59 -0700 Subject: [PATCH] feat(ssh): add per-target bounded output setting --- config/reliability-gates.jsonc | 56 ++++++++++-- .../2026-07-27-ssh-relay-pty-backpressure.md | 85 ++++++++++--------- src/main/ipc/ssh.ts | 4 +- src/main/persistence.test.ts | 41 +++++++++ src/main/persistence.ts | 8 ++ .../ssh/ssh-pty-source-credit-rollout.test.ts | 51 ++++++----- src/main/ssh/ssh-pty-source-credit-rollout.ts | 16 ++-- ...elay-session-reconnect-incarnation.test.ts | 62 ++++++++++++-- src/main/ssh/ssh-relay-session.ts | 10 ++- .../settings/SshHostAdvancedFields.test.tsx | 23 +++++ .../settings/SshHostAdvancedFields.tsx | 30 +++++++ .../settings/ssh-target-draft.test.ts | 15 ++++ .../components/settings/ssh-target-draft.ts | 6 +- .../settings/ssh-target-save-payload.test.ts | 22 ++++- .../settings/ssh-target-save-payload.ts | 2 + src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/shared/ssh-types.ts | 2 + 21 files changed, 363 insertions(+), 95 deletions(-) create mode 100644 src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index e1558bf3f15..81e1ca42c1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3082,15 +3082,15 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh"], - "coverageNotes": "Local macOS evidence covers main-process pending-output and projection-admission caps, runtime path-provenance history reuse, saturated-tail wait detection, direct-SSH source retention/accounting, exact provider-generation pause ownership, renderer exit/data ordering, decoder input bounds, bounded reconnect scheduling, and WSL stdio transport settlement through deterministic unit contracts. A joined main/runtime oracle exercises renderer-sourced headed semantics and headless-model snapshot semantics without launching a live paired server. Separately, a macOS-hosted Docker OpenSSH run exercises only the deployed Linux relay and direct SSH provider, with an exact 256 KiB source-credit plateau, concurrent PTY typing, fixed-size filesystem frames, Git churn, and owner-lease reconnect. Neither deterministic topology labels nor Docker SSH constitute live headed desktop, headless orca serve, or physical WSL evidence; local/daemon, prior-version daemons, Windows named pipes/ConPTY, folder workspaces, and mixed-version clients also remain uncovered.", + "coverageNotes": "Local macOS evidence covers main-process pending-output and projection-admission caps, runtime path-provenance history reuse, saturated-tail wait detection, direct-SSH source retention/accounting, per-target rollout persistence and session capture, exact provider-generation pause ownership, renderer exit/data ordering, decoder input bounds, bounded reconnect scheduling, and WSL stdio transport settlement through deterministic unit contracts. A joined main/runtime oracle exercises renderer-sourced headed semantics and headless-model snapshot semantics without launching a live paired server. Separately, a macOS-hosted Docker OpenSSH run exercises only the deployed Linux relay and direct SSH provider through the all-target environment override, with an exact 256 KiB source-credit plateau, concurrent PTY typing, fixed-size filesystem frames, Git churn, and owner-lease reconnect; it does not exercise the settings UI or persisted target selection. Neither deterministic topology labels nor Docker SSH constitute live headed desktop, headless orca serve, or physical WSL evidence; local/daemon, prior-version daemons, Windows named pipes/ConPTY, folder workspaces, and mixed-version clients also remain uncovered.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "High-volume terminal output must stay bounded across daemon socket writes, SSH relay writer/decoder/source retention/reconnect, main model and projection admission, runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input. Negotiated SSH spans remain exact and contiguous through activation, exit, cancellation, recovery, desktop projection, and required remote replacement; provisional activation data cannot project, reconnect may release it only into the exact attempt's private recovery quarantine after contract validation, and exit seals that quarantine against later same-token frames until ordered admission or exact cancellation proof. Reconnect checkpoint capture freezes exact provider-generation/PTy admission, cancels queued old work, and waits for the running raw completion before exposing its accepted checkpoint; timeout or failure detaches only that PTy's old model, makes the checkpoint unavailable, and releases the bounded fence without closing the shared provider, while the same failure outside an active exact migration remains generation-fatal and overlapping reconnects preserve any earlier outstanding fence. Pending renderer projection IDs are capped and compacted across split remainders, exit preparation owns its renderer fence through finalization, exit-time cancellation transfers published projections before proof commit, generation close fences late proof while draining exact projection waiters, closed-generation identity compacts without weakening stale rejection, canceled source-delivery retirement retains at most one ordered token per PTY, and pause/resume targets only the exact provider generation. Additional subscribers cannot stall the owner, while the required legacy primary retains backpressure.", - "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, a 1,024-ID projection cap with split-remainder compaction, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, exact provider-generation pause ownership, renderer exit/data ordering, and ACK-gated in-flight bounds. The joined source-intake/multiplex oracle blocks renderer or headless serialization, admits one snapshot-covered span and one trailing span, requires replacement reservation only after the authoritative sequence is known, advances upstream ACK eligibility exactly to the covered boundary after SnapshotEnd, and delivers then ACKs the trailing span through the ordinary live path without cancellation. Source-range ledger and multiplex seams additionally require cumulative byte ACK 40 then 100 for one 100-byte frame to release exact byte credit without early source settlement, admit a contiguous higher-generation recovered token while the prior token remains unsettled, and reject stale stream or source generations without releasing in-flight byte credit. Replacement reservation rolls back every earlier span on a later failure, commit requires the exact frozen transfer states, and an authoritatively reclaimed covered span rejects commit without local trim authority while rollback removes the reservation idempotently. The exit-deadline oracle publishes a source-backed desktop projection, advances a fake clock to cancellation, and requires one projection transfer, proof commit, preparation, and final exit with no provider close, retained obligation, duplicate finalization, or process-lifetime cancellation tombstone; a second controlled proof remains pending across generation close and must never publish final exit, while both paths release the renderer preparation lease exactly once. The model-migration oracle accepts span A, blocks span B's raw emulator completion, begins disconnect migration, and proves no checkpoint or reconnect attach can remain at A; releasing B advances the exact checkpoint to B and projects B once. Its fake-clock arm reaches the 10-second deadline, requires checkpoint-unavailable, one exact model reset, zero retained admission charge and timers, and no effect from late raw settlement. Its two-PTY failure arm rejects one running raw callback under an active exact migration and requires one checkpoint-unavailable result, one model reset, zero provider closes, retained sibling checkpoint and transport liveness, zero charges and timers, and an unaffected other generation; a non-migrating sibling oracle still requires generation close. The intake additionally closes 2,048 sequential provider generations into one exact range, rejects stale events across that range, and preserves an out-of-order live gap until its own close. Direct-SSH contracts additionally prove activation-response settlement before the first source frame, claim-gated projection under same-decoder-turn response/data delivery, exact early-ACK reservation through send settlement or same-token retry, recovery completion visible before lease-held frames transfers those frames only into the private quarantine and admits no output until exact checkpoint-to-recoveryEnd coverage, invalid-checkpoint restore retention through response settlement followed by fresh-token retry and one live source frame, isolated saturated-subscriber eviction with healthy owner liveness, preserved gate-off primary backpressure, one-write ownership across write(false)/drain, control selection ahead of queued ordinary PTY input with FIFO lanes and bounded fairness, liveness rebasing, failed-exit retention through late ACK or exact recovery, response-settlement cancellation authority, token-scoped exit cancellation, exact checkpoint-to-recoveryEnd continuity, stale-owner one-shot retry, no physical PTY teardown on recovery failure, exact provider-generation closure and once-only cleanup when recovery cancellation publication or proof rejects, 16 MiB-plus-1 MiB decoder caps, charged per-PTY/session retention budgets, and eight-wide isolated reattach. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, SSH relay writer/decoder/source retention/reconnect, main model and projection admission, runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input. Each SSH target defaults to bounded legacy delivery unless its persisted experimental option or an explicit environment override selects V1; one session captures that selection immutably for its initial connection and automatic reconnects. Negotiated SSH spans remain exact and contiguous through activation, exit, cancellation, recovery, desktop projection, and required remote replacement; provisional activation data cannot project, reconnect may release it only into the exact attempt's private recovery quarantine after contract validation, and exit seals that quarantine against later same-token frames until ordered admission or exact cancellation proof. Reconnect checkpoint capture freezes exact provider-generation/PTy admission, cancels queued old work, and waits for the running raw completion before exposing its accepted checkpoint; timeout or failure detaches only that PTy's old model, makes the checkpoint unavailable, and releases the bounded fence without closing the shared provider, while the same failure outside an active exact migration remains generation-fatal and overlapping reconnects preserve any earlier outstanding fence. Pending renderer projection IDs are capped and compacted across split remainders, exit preparation owns its renderer fence through finalization, exit-time cancellation transfers published projections before proof commit, generation close fences late proof while draining exact projection waiters, closed-generation identity compacts without weakening stale rejection, canceled source-delivery retirement retains at most one ordered token per PTY, and pause/resume targets only the exact provider generation. Additional subscribers cannot stall the owner, while the required legacy primary retains backpressure.", + "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, a 1,024-ID projection cap with split-remainder compaction, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, exact provider-generation pause ownership, renderer exit/data ordering, and ACK-gated in-flight bounds. The rollout seam proves independent target selection, explicit environment precedence, normalized durable opt-in through SSH-config refresh, Advanced-settings rendering/save/clear behavior, and immutable initial/reconnect session selection. The joined source-intake/multiplex oracle blocks renderer or headless serialization, admits one snapshot-covered span and one trailing span, requires replacement reservation only after the authoritative sequence is known, advances upstream ACK eligibility exactly to the covered boundary after SnapshotEnd, and delivers then ACKs the trailing span through the ordinary live path without cancellation. Source-range ledger and multiplex seams additionally require cumulative byte ACK 40 then 100 for one 100-byte frame to release exact byte credit without early source settlement, admit a contiguous higher-generation recovered token while the prior token remains unsettled, and reject stale stream or source generations without releasing in-flight byte credit. Replacement reservation rolls back every earlier span on a later failure, commit requires the exact frozen transfer states, and an authoritatively reclaimed covered span rejects commit without local trim authority while rollback removes the reservation idempotently. The exit-deadline oracle publishes a source-backed desktop projection, advances a fake clock to cancellation, and requires one projection transfer, proof commit, preparation, and final exit with no provider close, retained obligation, duplicate finalization, or process-lifetime cancellation tombstone; a second controlled proof remains pending across generation close and must never publish final exit, while both paths release the renderer preparation lease exactly once. The model-migration oracle accepts span A, blocks span B's raw emulator completion, begins disconnect migration, and proves no checkpoint or reconnect attach can remain at A; releasing B advances the exact checkpoint to B and projects B once. Its fake-clock arm reaches the 10-second deadline, requires checkpoint-unavailable, one exact model reset, zero retained admission charge and timers, and no effect from late raw settlement. Its two-PTY failure arm rejects one running raw callback under an active exact migration and requires one checkpoint-unavailable result, one model reset, zero provider closes, retained sibling checkpoint and transport liveness, zero charges and timers, and an unaffected other generation; a non-migrating sibling oracle still requires generation close. The intake additionally closes 2,048 sequential provider generations into one exact range, rejects stale events across that range, and preserves an out-of-order live gap until its own close. Direct-SSH contracts additionally prove activation-response settlement before the first source frame, claim-gated projection under same-decoder-turn response/data delivery, exact early-ACK reservation through send settlement or same-token retry, recovery completion visible before lease-held frames transfers those frames only into the private quarantine and admits no output until exact checkpoint-to-recoveryEnd coverage, invalid-checkpoint restore retention through response settlement followed by fresh-token retry and one live source frame, isolated saturated-subscriber eviction with healthy owner liveness, preserved gate-off primary backpressure, one-write ownership across write(false)/drain, control selection ahead of queued ordinary PTY input with FIFO lanes and bounded fairness, liveness rebasing, failed-exit retention through late ACK or exact recovery, response-settlement cancellation authority, token-scoped exit cancellation, exact checkpoint-to-recoveryEnd continuity, stale-owner one-shot retry, no physical PTY teardown on recovery failure, exact provider-generation closure and once-only cleanup when recovery cancellation publication or proof rejects, 16 MiB-plus-1 MiB decoder caps, charged per-PTY/session retention budgets, and eight-wide isolated reattach. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", @@ -3109,6 +3109,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/ssh/ssh-pty-source-credit-rollout.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/relay/dispatcher-client-writer.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-pty-source-credit-rollout.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/persistence.test.ts src/renderer/src/components/settings/ssh-target-draft.test.ts src/renderer/src/components/settings/ssh-target-save-payload.test.ts src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot", "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" @@ -3161,6 +3162,10 @@ "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts", "src/main/ssh/ssh-pty-source-credit-rollout.test.ts", "src/main/ssh/ssh-relay-deploy.test.ts", + "src/main/persistence.test.ts", + "src/renderer/src/components/settings/ssh-target-draft.test.ts", + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts", + "src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx", "src/relay/dispatcher-client-writer.test.ts", "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts", "tests/e2e/ssh-docker-relay-perf.spec.ts" @@ -3245,7 +3250,8 @@ { "file": "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts", "assertions": [ - "fifty reattaches use at most eight workers and healthy siblings finish before one slow and one failed PTY" + "fifty reattaches use at most eight workers and healthy siblings finish before one slow and one failed PTY", + "one target selection is captured before establish and remains unchanged through automatic reconnect" ] }, { @@ -3254,6 +3260,37 @@ "each transient PTY failure is retried once without tearing down successful provider registration" ] }, + { + "file": "src/main/ssh/ssh-pty-source-credit-rollout.test.ts", + "assertions": [ + "targets default off and opt in independently", + "an explicit all-target environment value takes precedence over persisted target state" + ] + }, + { + "file": "src/main/persistence.test.ts", + "assertions": [ + "only exact true persists, disabling clears the option, and SSH-config refresh preserves the Orca-owned target selection" + ] + }, + { + "file": "src/renderer/src/components/settings/ssh-target-draft.test.ts", + "assertions": [ + "editing preserves an enabled target option and reveals its Advanced fields" + ] + }, + { + "file": "src/renderer/src/components/settings/ssh-target-save-payload.test.ts", + "assertions": [ + "save persists enabled target state and explicitly clears disabled state on update" + ] + }, + { + "file": "src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx", + "assertions": [ + "Advanced settings render the experimental per-target switch enabled with concise recovery copy" + ] + }, { "file": "src/main/ssh/ssh-relay-session-model-migration.test.ts", "assertions": [ @@ -3573,6 +3610,15 @@ "durationSeconds": 1.86, "summary": "Seven current-worktree provider/session files passed 94 tests after a red deterministic seam reproduced completion visibility before lease-held recovery data; the candidate transfers held and later frames only into private recovery quarantine, final-commits after fence/admission, retires exited activation state, and preserves fail-closed malformed, gapped, overlapping, missing-body, cancellation-proof, and replacement behavior." }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-pty-source-credit-rollout.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/persistence.test.ts src/renderer/src/components/settings/ssh-target-draft.test.ts src/renderer/src/components/settings/ssh-target-save-payload.test.ts src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx --reporter=dot", + "result": "passed", + "durationSeconds": 20.19, + "summary": "Seven deterministic rollout, session, persistence, and renderer files passed 494 tests. The focused assertions prove per-target default-off opt-in, explicit environment precedence, normalized persistence through SSH-config refresh, Advanced-setting rendering/save/clear, and immutable selection through automatic reconnect; no live SSH or paired-runtime topology was exercised." + }, { "date": "2026-07-28", "runner": "local", @@ -3606,7 +3652,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. Restore-record retirement adds one exact map-identity check at response settlement with no polling, timers, scans, subprocesses, or provider calls. The Docker SSH gate enforces median/worst typing below 500/2,000 ms; the current private-transfer artifact observed 5.5/16.3 ms during an exact 256 KiB stalled-credit plateau, 141.2/151.5 ms during 95 completed filesystem/Git bulk reads, and a 16.0-second reconnect. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates." + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. Per-target selection adds one persisted boolean read and one constructor-time resolution per explicit session, with no polling, scans, subprocesses, provider fanout, or reconnect-path reevaluation. Restore-record retirement adds one exact map-identity check at response settlement with no polling, timers, scans, subprocesses, or provider calls. The Docker SSH gate enforces median/worst typing below 500/2,000 ms; the current private-transfer artifact observed 5.5/16.3 ms during an exact 256 KiB stalled-credit plateau, 141.2/151.5 ms during 95 completed filesystem/Git bulk reads, and a 16.0-second reconnect. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates." }, "promotionCriteria": [ "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", diff --git a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md b/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md index ab287db4bb1..3172c494031 100644 --- a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md +++ b/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md @@ -3,8 +3,8 @@ Date: 2026-07-27 Status: architecture gate closed and exact SSH V1 wire/API implemented behind -an experimental startup gate; integrated lifecycle fixes reconciled at current -HEAD; live topology gaps remain explicit below +an experimental per-target SSH setting; integrated lifecycle fixes reconciled +at current HEAD; live topology gaps remain explicit below Historical unbounded baseline: `badf91101babf96fa09cb79a8294f7e23b9f081c` (the implementation branch parent). The implementation was rebased onto @@ -349,7 +349,7 @@ findings as follows: | Desktop projection admission lacked immutable range identity and rollback | Model, source span, projection range, and scanner snapshot reserve atomically; pre-commit failure rolls back, while post-commit replacement transfers with proof. | Later adversarial review also required exact recovery continuity, stale-attempt -isolation, startup-latched rollout semantics, and idempotent remote detach +isolation, session-latched rollout semantics, and idempotent remote detach after token reclamation. Those are incorporated in the activation, lifecycle, cleanup, tests, and rollout sections below. @@ -482,11 +482,11 @@ prior-version isolation remain authoritative. Reusing the semantic state machine there is a later design decision and is not required to ship the SSH bound. -The optional V1 offer is present only when the main rollout gate is on. The -server intersects the offer with its supported versions and clamps -`windowSu`; omission selects bounded legacy delivery while still establishing -the role and generation. When V1 was offered, main accepts only a returned -version it offered and a finite safe integer satisfying +The optional V1 offer is present only when the SSH session's captured rollout +selection is on. The server intersects the offer with its supported versions +and clamps `windowSu`; omission selects bounded legacy delivery while still +establishing the role and generation. When V1 was offered, main accepts only a +returned version it offered and a finite safe integer satisfying `0 < windowSu <= requestedWindowSu`. An absent or invalid V1 grant closes that connection attempt rather than silently downgrading it. @@ -507,7 +507,7 @@ and projection work. Transport close cancels the connection's retained publications and reconnect uses the existing replay/restore behavior. It may pause all legacy subscribers behind one slow connection because legacy mode has no authenticated source-owner role, but it neither drops output nor grows -without bound. These mechanics remain enabled when the V1 rollout gate is off. +without bound. These mechanics remain enabled when the target's V1 option is off. The session hello creates no PTY token. A later V1 spawn/attach returns a fresh `deliveryToken` and creates a subscription only for the authenticated @@ -516,8 +516,8 @@ context just as `pty.spawn` does. Spawn failure, identity mismatch, stale context, and response cancellation create no active V1 subscription. A legacy spawn/attach returns the current identity/replay shape and creates only the bounded transport subscription above—no token or source coordinate. The -startup-latched mode never rotates a live V1 token into legacy service; normal -shutdown closes the old token before a restarted gate-off main opens a bounded +session-latched mode never rotates a live V1 token into legacy service; normal +disconnect closes the old token before a new gate-off session opens a bounded legacy connection. Session ownership is granted by the authenticated session hello, not @@ -560,20 +560,23 @@ launches detach on POSIX and Windows, invalidate stdout, and connect the desktop bridge through `attachClient` over the versioned Unix socket or named pipe. Unproved direct stdio remains subscriber-only. -Capability support and rollout enablement are separate. Fresh POSIX and -Windows gate-on launches receive the safely quoted +Capability support and rollout enablement are separate. Each `SshRelaySession` +captures its target's `experimentalPtySourceCreditV1` selection at construction; +`ORCA_SSH_PTY_SOURCE_CREDIT_V1=0|1` remains an explicit CI/development +override. Fresh POSIX and Windows gate-on launches receive the safely quoted `--pty-source-credit-v1` flag; gate-off omits it. The endpoint's persisted `.pty-source-credit-policy` records `v1` or `off` and fences incompatible reuse. A new main may still decline V1 from an already-running capable relay. -Changing the gate requires a main-process restart and never changes a live -token's semantics. Sink drain, writer ordering, decoder bounds, and header-ACK -hardening are correctness fixes and are not disabled by this gate. +Editing a target never changes a live session or its automatic reconnects; a +later explicit connection captures the new selection. Sink drain, writer +ordering, decoder bounds, and header-ACK hardening are correctness fixes and +are not disabled by this gate. Production deployment does not form arbitrary mixed-build main/relay pairs. `computeRemoteRelayDir` content-hash-scopes the install directory and its socket/named-pipe endpoint, and the `.version` handshake is a second fence, so the desktop bridge and daemon reached at that endpoint share a build. Reachable -same-build modes are session-granted V1 and main-gated legacy. A missing +same-build modes are session-granted V1 and target-gated legacy. A missing mandatory session grant fails readiness. Protocol tolerance for unknown fields remains for direct/manual relay launches, but an absent session contract is diagnostic `unsupported-version-skew`, not a rollout cohort. @@ -581,9 +584,9 @@ diagnostic `unsupported-version-skew`, not a rollout cohort. The reachable upgrade skew is an orphaned prior-version daemon in its old version directory with live PTYs while the new main connects to a new endpoint. It retains its old behavior until its own grace/cleanup completes and is not -reachable by the new main's startup gate. Upgrade diagnostics enumerate these -versioned orphan processes; V1 neither adopts their PTYs nor claims to bound -their memory. +reachable by a new session's rollout selection. Upgrade diagnostics enumerate +these versioned orphan processes; V1 neither adopts their PTYs nor claims to +bound their memory. Unknown fields are never capability proof. Place the transport-neutral state types with the narrowest shared session package, while relay/daemon/runtime @@ -2385,7 +2388,7 @@ boundaries even though they ship in one PR. - `SshPtyModelAdmission` retains generation-fatal callback handling except when the exact key is actively migrating; `SshPtyOutputIntake` uses the same key to keep provider close from escaping that migration owner. -- Reconnect V1 remains under the startup gate; its late-ACK, timeout, +- Reconnect V1 remains under the session-latched target gate; its late-ACK, timeout, supersession, and generation-close deterministic oracles pass. ### Slice 5d: required remote consumers — deterministic seam implemented @@ -2753,8 +2756,8 @@ one last chunk to prove the transient overshoot bound. ### Integration and E2E `tests/e2e/ssh-docker-relay-perf.spec.ts` now contains V1 evidence for the -implemented Linux SSH/deployed-relay path. With -`ORCA_SSH_PTY_SOURCE_CREDIT_V1=1`, it: +implemented Linux SSH/deployed-relay path. The test harness uses the +`ORCA_SSH_PTY_SOURCE_CREDIT_V1=1` all-target override and: 1. stalls desktop ACK and observes an exact 256 Ki-source-unit negotiated plateau while a second SSH PTY remains responsive; @@ -2807,14 +2810,15 @@ is deterministic adapter evidence only, not a physical WSL run. ## Rollout -V1 is behind a main-process startup gate and a persisted per-endpoint relay -launch policy. `ORCA_SSH_PTY_SOURCE_CREDIT_V1` is read once when the main -process starts; it is not a live kill switch. Tests and development enable the -gate before startup. Main never negotiates merely because a long-lived relay -advertises; its startup-latched gate is the final authority. Fresh POSIX and -Windows gate-on launch commands carry `--pty-source-credit-v1`; gate-off omits -it, and the persisted endpoint policy records the selected mode rather than -assuming a local environment variable reaches the remote process. +V1 is behind `Bounded terminal output (experimental)` in each SSH target's +Advanced settings and a persisted per-endpoint relay launch policy. Missing or +false target state selects legacy mode. `ORCA_SSH_PTY_SOURCE_CREDIT_V1=0|1` +explicitly overrides every target for CI, development, and emergency rollback. +Main never negotiates merely because a long-lived relay advertises; the +immutable selection captured by a new `SshRelaySession` is final for that +session and all of its automatic reconnects. Fresh POSIX and Windows gate-on +launch commands carry `--pty-source-credit-v1`; gate-off omits it, and the +persisted endpoint policy records the selected mode. Sink drain, the reserved writer lane, stdout serialization, bounded decoder work, bulk admission, and hostile header-ACK hardening remain enabled @@ -2826,7 +2830,7 @@ Implementation and rollout stages: 1. Slices 1-4 are implemented under legacy-compatible framing with narrow reliability oracles. -2. Slices 5a-5c are implemented behind the startup gate and relay launch +2. Slices 5a-5c are implemented behind the per-target gate and relay launch policy with unit/service contracts and prior direct Docker SSH evidence. 3. Slices 5d-5e implement remote source-range transactions and the final lifecycle reconciliation at deterministic main/runtime/provider/relay @@ -2839,19 +2843,20 @@ Implementation and rollout stages: final exact-implementation-head architecture and transport reviews found no code blocker, and the transport review's stale-provenance finding is closed by the exact artifact record above. -5. Run an internal same-build canary comparing gate-off and V1 sink, +5. Run an internal same-build per-target canary comparing gate-off and V1 sink, model-admission, latency, sealed-exit, recovery, and reconnect metrics while separately counting orphan prior-version daemons; 6. Default on only after zero ordering/data-loss failures and stable memory - plateaus for one release cycle; remove the flag only after same-build legacy - fallback and versioned-orphan cleanup stay healthy through the supported - upgrade window. + plateaus for one release cycle; remove the per-target opt-in only after + same-build legacy fallback and versioned-orphan cleanup stay healthy through + the supported upgrade window. Rollback behavior is explicit: -1. Change the environment/launch setting, then restart the main process. The - existing process keeps its original mode until shutdown. -2. Shutdown performs the normal token-scoped cancellation/exit cleanup; it +1. Change the target's Advanced setting, then explicitly disconnect and + reconnect that target. Existing sessions and automatic reconnects keep + their captured mode. +2. Disconnect performs the normal token-scoped cancellation/exit cleanup; it does not rotate live V1 tokens into legacy mode in place. 3. On the next connection, gate-off omits the V1 offer and accepts same-build bounded legacy service. Gate-on requires a V1-launched endpoint. @@ -2863,7 +2868,7 @@ Rollback behavior is explicit: because omission of the offer selects bounded legacy behavior. The remote process may remain alive only when its persisted launch policy is -compatible with the new startup mode. An orphaned prior-version daemon is +compatible with the new session selection. An orphaned prior-version daemon is outside this sequence and is only observed until its own version-scoped cleanup. diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ad8abb87398..f0c36aa5314 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -7,7 +7,7 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' -import { isSshPtySourceCreditV1Enabled } from '../ssh/ssh-pty-source-credit-rollout' +import { resolveSshPtySourceCreditV1Selection } from '../ssh/ssh-pty-source-credit-rollout' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, @@ -1023,7 +1023,7 @@ export function registerSshHandlers( portForwardManager!, currentRuntime, broadcastDetectedPortsFromCurrentWindow, - isSshPtySourceCreditV1Enabled + () => resolveSshPtySourceCreditV1Selection(target) ) configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9c0b0d47e9e..242edace0f2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1548,6 +1548,43 @@ describe('Store', () => { expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse') }) + it('persists only explicit SSH terminal source-credit opt-ins', async () => { + const store = await createStore() + store.addSshTarget({ + id: 'ssh-source-credit-on', + label: 'Noisy build host', + host: 'build.example.com', + port: 22, + username: 'dev', + experimentalPtySourceCreditV1: true + }) + store.addSshTarget({ + id: 'ssh-source-credit-off', + label: 'Legacy host', + host: 'legacy.example.com', + port: 22, + username: 'dev', + experimentalPtySourceCreditV1: false + }) + + expect(store.getSshTarget('ssh-source-credit-on')?.experimentalPtySourceCreditV1).toBe(true) + expect(store.getSshTarget('ssh-source-credit-off')).not.toHaveProperty( + 'experimentalPtySourceCreditV1' + ) + + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + const enabled = persisted.sshTargets?.find((target) => target.id === 'ssh-source-credit-on') + const disabled = persisted.sshTargets?.find((target) => target.id === 'ssh-source-credit-off') + expect(enabled?.experimentalPtySourceCreditV1).toBe(true) + expect(disabled).not.toHaveProperty('experimentalPtySourceCreditV1') + + const updated = store.updateSshTarget('ssh-source-credit-on', { + experimentalPtySourceCreditV1: undefined + }) + expect(updated).not.toHaveProperty('experimentalPtySourceCreditV1') + }) + it('upserts ~/.ssh/config through the real store: rotated port updates in place and persists', async () => { loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) const candidate = (port: number, id: string) => [ @@ -1563,6 +1600,7 @@ describe('Store', () => { expect(inserted).toHaveLength(1) expect(inserted[0]?.source).toBe('ssh-config') expect(inserted[0]?.port).toBe(2200) + store.updateSshTarget('ssh-cfg-1', { experimentalPtySourceCreditV1: true }) // Rotated port: upsert updates the same target in place and normalizeSshTarget must keep `source` (no false re-derive into a permanently-dirty state). sshConfigHostsToTargetsMock.mockReturnValue(candidate(2222, 'ssh-cfg-2')) @@ -1570,6 +1608,7 @@ describe('Store', () => { expect(changed).toHaveLength(1) expect(changed[0]?.port).toBe(2222) expect(changed[0]?.source).toBe('ssh-config') + expect(changed[0]?.experimentalPtySourceCreditV1).toBe(true) // A third identical sync is a no-op — repeated auto-sync on every pane open writes nothing. expect(sshStore.importFromSshConfig()).toHaveLength(0) @@ -1581,12 +1620,14 @@ describe('Store', () => { expect(clusterTargets).toHaveLength(1) expect(clusterTargets[0]?.port).toBe(2222) expect(clusterTargets[0]?.source).toBe('ssh-config') + expect(clusterTargets[0]?.experimentalPtySourceCreditV1).toBe(true) // Survives a fresh load from the same data file. const reloaded = await createStore() const reloadedCluster = reloaded.getSshTargets().find((t) => t.configHost === 'cluster') expect(reloadedCluster?.port).toBe(2222) expect(reloadedCluster?.source).toBe('ssh-config') + expect(reloadedCluster?.experimentalPtySourceCreditV1).toBe(true) }) it('drops malformed migration-unsupported PTY entries on load', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d0f27346e83..8256337e2ec 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1122,11 +1122,13 @@ function normalizeSshTarget(t: SshTarget): SshTarget { const currentGracePeriodSeconds = target.relayGracePeriodSeconds const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds const systemSshConnectionReuse = target.systemSshConnectionReuse + const experimentalPtySourceCreditV1 = target.experimentalPtySourceCreditV1 // Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load. delete target.remoteWorkspaceSyncEnabled delete target.remoteWorkspaceSyncGracePeriodSeconds delete target.relayGracePeriodSeconds delete target.systemSshConnectionReuse + delete target.experimentalPtySourceCreditV1 // Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration. const relayGracePeriodSeconds = legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' @@ -1146,6 +1148,9 @@ function normalizeSshTarget(t: SshTarget): SshTarget { if (systemSshConnectionReuse === false) { normalized.systemSshConnectionReuse = false } + if (experimentalPtySourceCreditV1 === true) { + normalized.experimentalPtySourceCreditV1 = true + } return normalized } @@ -6256,6 +6261,9 @@ export class Store { if (!Object.hasOwn(normalized, 'systemSshConnectionReuse')) { delete target.systemSshConnectionReuse } + if (!Object.hasOwn(normalized, 'experimentalPtySourceCreditV1')) { + delete target.experimentalPtySourceCreditV1 + } this.scheduleSave() return { ...target } } diff --git a/src/main/ssh/ssh-pty-source-credit-rollout.test.ts b/src/main/ssh/ssh-pty-source-credit-rollout.test.ts index 06b7f863989..466a12aadaa 100644 --- a/src/main/ssh/ssh-pty-source-credit-rollout.test.ts +++ b/src/main/ssh/ssh-pty-source-credit-rollout.test.ts @@ -1,17 +1,41 @@ import { describe, expect, it } from 'vitest' import { assertSshPtySourceCreditRelayLaunchPolicy, - isSshPtySourceCreditV1Enabled, + resolveSshPtySourceCreditV1Selection, SshPtySourceCreditRestartRequiredError, SSH_PTY_SOURCE_CREDIT_V1_ENV } from './ssh-pty-source-credit-rollout' describe('SSH PTY source-credit rollout', () => { - it('defaults off and requires the exact opt-in value', () => { - expect(isSshPtySourceCreditV1Enabled({})).toBe(false) - expect(isSshPtySourceCreditV1Enabled({ [SSH_PTY_SOURCE_CREDIT_V1_ENV]: '0' })).toBe(false) - expect(isSshPtySourceCreditV1Enabled({ [SSH_PTY_SOURCE_CREDIT_V1_ENV]: 'true' })).toBe(false) - expect(isSshPtySourceCreditV1Enabled({ [SSH_PTY_SOURCE_CREDIT_V1_ENV]: '1' })).toBe(true) + it('defaults off and permits a per-target opt-in', () => { + expect(resolveSshPtySourceCreditV1Selection({}, {})).toBe(false) + expect(resolveSshPtySourceCreditV1Selection({ experimentalPtySourceCreditV1: false }, {})).toBe( + false + ) + expect(resolveSshPtySourceCreditV1Selection({ experimentalPtySourceCreditV1: true }, {})).toBe( + true + ) + }) + + it('lets an explicit environment value override the target selection', () => { + expect( + resolveSshPtySourceCreditV1Selection( + { experimentalPtySourceCreditV1: true }, + { [SSH_PTY_SOURCE_CREDIT_V1_ENV]: '0' } + ) + ).toBe(false) + expect( + resolveSshPtySourceCreditV1Selection( + { experimentalPtySourceCreditV1: true }, + { [SSH_PTY_SOURCE_CREDIT_V1_ENV]: 'true' } + ) + ).toBe(false) + expect( + resolveSshPtySourceCreditV1Selection( + { experimentalPtySourceCreditV1: false }, + { [SSH_PTY_SOURCE_CREDIT_V1_ENV]: '1' } + ) + ).toBe(true) }) it('surfaces a deliberate restart action for a live off-to-on transition', () => { @@ -33,19 +57,4 @@ describe('SSH PTY source-credit rollout', () => { SshPtySourceCreditRestartRequiredError ) }) - - it('keeps the process-start selection stable across later environment mutation', () => { - const startupSelection = isSshPtySourceCreditV1Enabled() - const previous = process.env[SSH_PTY_SOURCE_CREDIT_V1_ENV] - process.env[SSH_PTY_SOURCE_CREDIT_V1_ENV] = startupSelection ? '0' : '1' - try { - expect(isSshPtySourceCreditV1Enabled()).toBe(startupSelection) - } finally { - if (previous === undefined) { - delete process.env[SSH_PTY_SOURCE_CREDIT_V1_ENV] - } else { - process.env[SSH_PTY_SOURCE_CREDIT_V1_ENV] = previous - } - } - }) }) diff --git a/src/main/ssh/ssh-pty-source-credit-rollout.ts b/src/main/ssh/ssh-pty-source-credit-rollout.ts index 34bb316266e..f5a7ead071a 100644 --- a/src/main/ssh/ssh-pty-source-credit-rollout.ts +++ b/src/main/ssh/ssh-pty-source-credit-rollout.ts @@ -1,13 +1,17 @@ +import type { SshTarget } from '../../shared/ssh-types' + export const SSH_PTY_SOURCE_CREDIT_V1_ENV = 'ORCA_SSH_PTY_SOURCE_CREDIT_V1' -function readSshPtySourceCreditV1Selection(env: NodeJS.ProcessEnv): boolean { - return env[SSH_PTY_SOURCE_CREDIT_V1_ENV] === '1' +function readSshPtySourceCreditV1Override(env: NodeJS.ProcessEnv): boolean | undefined { + const value = env[SSH_PTY_SOURCE_CREDIT_V1_ENV] + return value === undefined ? undefined : value === '1' } -const SSH_PTY_SOURCE_CREDIT_V1_STARTUP_SELECTION = readSshPtySourceCreditV1Selection(process.env) - -export function isSshPtySourceCreditV1Enabled(env?: NodeJS.ProcessEnv): boolean { - return env ? readSshPtySourceCreditV1Selection(env) : SSH_PTY_SOURCE_CREDIT_V1_STARTUP_SELECTION +export function resolveSshPtySourceCreditV1Selection( + target: Pick, + env: NodeJS.ProcessEnv = process.env +): boolean { + return readSshPtySourceCreditV1Override(env) ?? target.experimentalPtySourceCreditV1 === true } export class SshPtySourceCreditRestartRequiredError extends Error { diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 1ac80f814e4..d37476f7e3c 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -2,19 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' -const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({ +const { acceptOutputExitMock, muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn() + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn( + async (_mux: unknown, options: { clientInstanceId: string; outputFlowControl?: unknown }) => ({ + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + }) + ) })) vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) + openSshPtyConsumerSession: openConsumerSessionMock })) vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), @@ -27,7 +30,8 @@ vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ closeSshPtyOutputGeneration: vi.fn(), getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}) + installSshPtySourceAckPublisher: vi.fn(() => () => {}), + installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) })) vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) vi.mock('./ssh-channel-multiplexer', () => ({ @@ -96,6 +100,7 @@ const { setPtyOwnership, restorePtyIncarnation } = await import('../ipc/pty') +const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') const APP_PTY_ID = 'ssh:target-1@@pty-live' const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -209,6 +214,45 @@ describe('SshRelaySession reconnect incarnation ordering', () => { } }) + it('keeps source-credit selection immutable through reconnect', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + let sourceCreditEnabled = true + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + undefined, + undefined, + () => sourceCreditEnabled + ) + sourceCreditEnabled = false + + await session.establish(mockConn) + await session.reconnect(mockConn) + + expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( + 1, + mockConn, + undefined, + undefined, + 'target-1', + true + ) + expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( + 2, + mockConn, + undefined, + undefined, + 'target-1', + true + ) + expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) + for (const [, options] of openConsumerSessionMock.mock.calls) { + expect(options.outputFlowControl).toBeDefined() + } + }) + it('rolls back a timed-out activation that resolves after its replacement commits', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 39aa421b729..b7b543e1975 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -282,6 +282,7 @@ export class SshRelaySession { >() private readonly retiredSourceDeliveries = new SshPtyRetiredSourceDeliveries() private readonly ptyConsumerClientInstanceId: string + private readonly ptySourceCreditEnabled: boolean private ptyConsumerSessionState: SshPtyConsumerSessionState | null = null constructor( @@ -295,9 +296,10 @@ export class SshRelaySession { ports: DetectedPort[], platform: string ) => void, - private readonly isPtySourceCreditEnabled: () => boolean = () => false + isPtySourceCreditEnabled: () => boolean = () => false ) { this.ptyConsumerClientInstanceId = ptyConsumerRecoveryForTarget(targetId).clientInstanceId + this.ptySourceCreditEnabled = isPtySourceCreditEnabled() } refreshEnvironment( @@ -398,7 +400,7 @@ export class SshRelaySession { undefined, graceTimeSeconds, this.targetId, - this.isPtySourceCreditEnabled() + this.ptySourceCreditEnabled ) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -519,7 +521,7 @@ export class SshRelaySession { undefined, graceTimeSeconds, this.targetId, - this.isPtySourceCreditEnabled() + this.ptySourceCreditEnabled ) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -855,7 +857,7 @@ export class SshRelaySession { clientInstanceId: this.ptyConsumerClientInstanceId, expectedServerBuildId: serverBuildId, allowSameBuildLegacyFallback: true, - ...(this.isPtySourceCreditEnabled() + ...(this.ptySourceCreditEnabled ? { outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } } : {}) } diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx new file mode 100644 index 00000000000..c7722b262b5 --- /dev/null +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { SshHostAdvancedFields } from './SshHostAdvancedFields' +import { EMPTY_FORM } from './ssh-target-draft' + +describe('SshHostAdvancedFields', () => { + it('renders bounded terminal output as a per-target experimental option', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Bounded terminal output (experimental)') + expect(markup).toContain('Limits buffered terminal output') + expect(markup).toContain('aria-checked="true"') + expect(markup).toContain('Toggle bounded terminal output') + }) +}) diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx index 3be7dca8fce..16c5608f629 100644 --- a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -113,6 +113,36 @@ export function SshHostAdvancedFields({ )} /> +
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.ptySourceCreditV1Description', + 'Limits buffered terminal output and improves recovery when this SSH target reconnects.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + experimentalPtySourceCreditV1: !f.experimentalPtySourceCreditV1 + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.ptySourceCreditV1Toggle', + 'Toggle bounded terminal output' + )} + /> +