Skip to content

fix(ssh): saved port forwards come back after a restart that reconnects through the host's Orca server (#32484) - #33388

Merged
nwparker merged 12 commits into
mainfrom
nwparker/fix-32484-forward-persistence
Oct 25, 2026
Merged

nwparker merged 12 commits into
mainfrom
nwparker/fix-32484-forward-persistence

Conversation

@nwparker

@nwparker nwparker commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​330 $\color{#cf222e}{\Huge{\mathbf{−}}}$​22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​308
Prod 12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​278 $\color{#cf222e}{\Huge{\mathbf{−}}}$​101 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​177

ELI5

When Orca restarted, saved SSH port forwards sometimes didn't come back. The app only turned them back on during an explicit SSH connect. A host the app reconnected through its Orca server's tunnel at startup never ran that connect, so the forwards stayed off and the Ports panel listed none.

What Changed

  • Before, restore had two gaps:
    • Restore only ran from the SSH connect path, or from a transport reconnect when the host already showed as managed.
    • At startup the managed server's tunnel can bring the host up with no SSH connect. Its status then arrives from the update check or start listener, which never restored. When the update check or a redeploy failed, no status arrived at all.
  • Before, user edits erased saved forwards:
    • Add, edit and remove saved only the forwards open at that moment.
    • After a restart that restored nothing, re-adding one forward erased every other saved forward.
  • After, restore now triggers in two places:
    • Every served status (a managed server, or a host the legacy relay bridge keeps connected) triggers the restore, from any publisher.
    • Every call that brings up the managed server's tunnel also triggers it, whether or not a status is ever published.
    • Unsettled statuses (setting up, or unserved and not bridged) never restore.
  • After, how often restore runs:
    • A new transport restores at once, and a reconnect forgets earlier failures.
    • On the same transport, restore runs at most once per 30 s, so per-call triggers can't spawn an SSH login per call.
    • A forward that keeps failing waits 30 s, then 60 s, and so on up to 10 min.
    • A start that a teardown cancelled records no failure.
    • Teardown and host removal forget all pacing state.
    • A tunnel with no status yet counts only outside an SSH connect.
  • After, every user edit merges with the saved list:
    • Add and edit keep saved forwards that aren't open yet.
    • Remove and edit drop only the mapping they replace.
    • A new forward on the same local port replaces an old unrestored mapping for that port.
  • After, ordering: add, edit, remove and restore for a host run in order through one queue. A restore can't reopen a mapping that an edit in progress is replacing, or bind the same port twice.

Why

Hooking each publisher separately is how the gap opened. The relay path used to restore, then the managed connect path was added, and the tunnel path was missed. Triggering on the status itself, plus on the tunnel coming up, covers every way a host comes back. Merging on write means a gap like this can no longer erase saved settings. One queue per host for every forward change removes the restore/edit races, which avoids guarding each one separately.

Credit to @Fosol for the report and the detail that nothing was even listed after a restart.

Linked Issue

Fixes #32484

Visual Proof

N/A. This restores existing behaviour in the main process, and the Docker e2e below shows it working.

Testing

  • Automated tests added/updated
  • New Docker e2e case in tests/e2e/ssh-orcad-workspace-tools.spec.ts: "across a restart without an SSH connect". Every ssh:connect is forbidden, so only the server's tunnel brings the host back.
  • On origin/main plus only the tests (run 40160143772), the tunnel-only restart fails with fetch failed on the forwarded port. With the fix, all cases pass on the final head (run 40169347641).
  • Unit tests that fail on main and pass here:
    • a served status, managed or bridged, restores saved forwards;
    • a status that is still setting up doesn't restore;
    • restore runs over the tunnel even with no status;
    • restore runs once per transport, and again after a forward closes;
    • a closed forward isn't respawned on every call;
    • a forward that keeps failing backs off;
    • a cancelled start records no backoff;
    • a reconnect retries at once;
    • a tunnel mid-connect doesn't restore;
    • a remove queued behind an edit that replaced its forward does nothing;
    • add and remove keep unrestored forwards;
    • edit drops only the old mapping;
    • a reused local port replaces the old mapping;
    • a restore waits for an edit in progress.
    • Files: ssh.test.ts, ssh-port-forward-restore.test.ts, ssh-port-forward-handlers.test.ts, runtime-environment-managed-tunnel.test.ts.
  • All 76 test files that import the changed modules pass single-worker. The full static job passes locally.
  • Verified live: the Linux Docker managed host in CI only. Not verified on a Windows client.

Follow-up: a separate PR will list saved forwards that couldn't start, with the reason, and let you remove them.

Review

Codex review against pinned base 407e9eb: the final head is clean. Earlier rounds found P2s, all fixed here: a failed bind blocked retries, a closed forward wasn't restored again, a reused port kept its old mapping, a restore could race an edit, a cancelled start recorded a backoff, and a reconnect inherited failures. An independent Opus review found no P0/P1; its P2/P3 items are fixed here.

Agent skill upstream boundary

  • Not applicable

Notes

Remote/mixed versions: client-only, with no wire change. Folder workspaces: forwards are per SSH host, not per workspace, so they aren't affected. Cross-platform: no platform-specific code.

Recovery: forwards that were never touched after a failed restore are still saved, and they reopen with this fix. Forwards erased when the user re-added another one before this fix can't be recovered, so they must be added once more.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • N/A visual, with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Typecheck, lint and focused tests pass locally

… not only after an SSH connect (#32484)

A host whose managed server the app reaches at startup through its tunnel
publishes its served status from the tunnel path, which never ran the
forward restore. The served status itself now triggers the restore, from
any publisher, and a host kept on the legacy relay bridge restores too.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 41 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a3a5490e-686b-4fb8-b650-eff6c7125461
Commits

Reviewing files that changed from the base of the PR and between 186d62e and 8553b58.

Files selected for processing: 19
  • src/main/ipc/runtime-environments/runtime-environment-managed-tunnel.test.ts
  • src/main/ipc/runtime-environments/runtime-environment-managed-tunnel.ts
  • src/main/ipc/ssh/ssh-host-server-connect.ts
  • src/main/ipc/ssh/ssh-ipc-mock-shapes.ts
  • src/main/ipc/ssh/ssh-ipc-module-mocks.ts
  • src/main/ipc/ssh/ssh-ipc-test-harness.ts
  • src/main/ipc/ssh/ssh-managed-host-port-forward-lifecycle.test.ts
  • src/main/ipc/ssh/ssh-managed-port-forward-restore.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.test.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.ts
  • src/main/ipc/ssh/ssh-port-forward-persistence.ts
  • src/main/ipc/ssh/ssh-port-forward-restore.test.ts
  • src/main/ipc/ssh/ssh-port-forward-unrestored.ts
  • src/main/ipc/ssh/ssh-session-teardown.ts
  • src/main/ipc/ssh/ssh.test.ts
  • src/main/ipc/ssh/ssh.ts
  • src/main/ssh/ssh-host-server-status.ts
  • tests/e2e/helpers/orcad-managed-workspace.ts
  • tests/e2e/ssh-orcad-workspace-tools.spec.ts
Walkthrough

SSH port-forward persistence retains saved forwards that are not active, unless they are explicitly dropped. Restoration runs for managed or bridged-relay hosts, and can also run when the managed tunnel is up and host status is absent. The code serializes forward changes, tracks restore attempts by connection generation, and applies backoff after failures. Tests cover persistence, served statuses, tunnel setup, reconnects, and app restarts.

Priority: Pending latest changes

Severity of issue fixed: Medium

Merge Risk: Moderate · up to 186d6

A reconnect may leave a saved port forward unusable, and a teardown-interrupted restore may delay its return. Resolve these restoration risks before merging.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 27 functions across 18 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: restoring saved SSH port forwards after an Orca restart and host reconnect.
Linked Issues check Passed Issue #32484 requires saved SSH port-forward mappings to remain and work after Orca restarts and reconnects to the remote host. The whole-PR summary shows these changes for that requirement:
- src/mai…
Out of Scope Changes check Passed Every production change in the summary relates to restoring or preserving saved SSH port forwards after restart, which is the scope of #32484:
- Restore triggers: ssh-host-server-connect.ts (removed r…
Description check Passed The description follows the template well. It has an ELI5, a detailed What Changed list, a Why section, a linked issue (Fixes #32484), a Visual Proof section marked N/A with a reason, a Testing sectio…
Full details: Linked Issues check

Explanation

Issue #32484 requires saved SSH port-forward mappings to remain and work after Orca restarts and reconnects to the remote host. The whole-PR summary shows these changes for that requirement:

  • src/main/ssh/ssh-host-server-status.ts adds a served-status listener. ssh.ts restores saved forwards when a host reports a served status (managed, or relay with bridged === true).
  • runtime-environment-managed-tunnel.ts restores saved forwards after the managed tunnel comes up, with tunnelUp: true. This covers startup when no SSH connect runs and no status is published.
  • ssh-managed-port-forward-restore.ts restores once per transport and again after a restored forward closes. Failed binds are retried with backoff (30 s start, 10 min cap). Unsettled statuses do not restore.
  • ssh-port-forward-persistence.ts and ssh-port-forward-handlers.ts keep unrestored saved forwards on add, edit, and remove. Edits and removals run through one per-host queue.
    Tests cover these paths. Unit tests cover served status, setting-up status, tunnel-only restore, transport and retry behavior, persistence on add/remove, local-port reuse, and edit/restore ordering. The Docker e2e cases in ssh-orcad-workspace-tools.spec.ts cover restart with an SSH connect and restart with every ssh:connect forbidden. The PR reports that the tunnel-only case fails on main and passes on the final head. The previous PASS remains supported by the current summary. The issue does not require a platform-specific fix. Verification was on Linux only. This is a residual risk, not a missing requirement.
Full details: Out of Scope Changes check

Explanation

Every production change in the summary relates to restoring or preserving saved SSH port forwards after restart, which is the scope of #32484:

  • Restore triggers: ssh-host-server-connect.ts (removed restore call), ssh.ts, ssh-host-server-status.ts, runtime-environment-managed-tunnel.ts.
  • Restore logic and retry state: ssh-managed-port-forward-restore.ts, ssh-port-forward-unrestored.ts, ssh-port-forward-persistence.ts.
  • Edit and remove persistence and ordering: ssh-port-forward-handlers.ts, ssh-port-forward-persistence.ts.
  • Teardown cleanup of restore state: ssh-session-teardown.ts.
    The test, mock, harness, and e2e helper changes (ssh-ipc-mock-shapes.ts, ssh-ipc-module-mocks.ts, ssh-ipc-test-harness.ts, orcad-managed-workspace.ts, and the test files) support these behaviors. The relaunch helper change adds an optional launch-options parameter only for the restart e2e case. The PR says this is client-only with no wire change. No unrelated product behavior appears in the summary.
Full details: Description check

Explanation

The description follows the template well. It has an ELI5, a detailed What Changed list, a Why section, a linked issue (Fixes #32484), a Visual Proof section marked N/A with a reason, a Testing section with specific tests and e2e runs, a Review section, a Notes section covering cross-platform, remote/SSH, and backwards compatibility, and a checked checklist. The AI Disclosure section is absent. The template says to fill it in for non-StablyAI contributors, and the description does not say whether AI was used. The description does not state whether the 'Testing' manual test checkbox was checked, but it lists automated tests and says local verification was done. Overall the description is mostly complete.

Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…once the tunnel is up

Add, edit and remove now merge with saved forwards that aren't live, so a
restart that restored nothing no longer lets one re-added forward erase
the rest. A call that brings up the managed server's tunnel restores too,
so a failed update check or redeploy can't skip it, and a restore runs once
per transport instead of on every status note.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c1cb51a7-fa58-4b80-8078-6eb17a6cf27d
Commits

Reviewing files that changed from the base of the PR and between 3214904 and 126afab.

Files selected for processing: 9
  • src/main/ipc/runtime-environments/runtime-environment-managed-tunnel.test.ts
  • src/main/ipc/runtime-environments/runtime-environment-managed-tunnel.ts
  • src/main/ipc/ssh/ssh-managed-port-forward-restore.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.test.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.ts
  • src/main/ipc/ssh/ssh-port-forward-persistence.ts
  • src/main/ipc/ssh/ssh-port-forward-restore.test.ts
  • src/main/ipc/ssh/ssh.test.ts
  • src/main/ipc/ssh/ssh.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4ae3104e-7c3d-4d19-8442-f14a0c62cb02
Commits

Reviewing files that changed from the base of the PR and between 126afab and f05cd6d.

Files selected for processing: 3
  • src/main/ipc/ssh/ssh-managed-port-forward-restore.ts
  • src/main/ipc/ssh/ssh-port-forward-persistence.ts
  • src/main/ipc/ssh/ssh-port-forward-restore.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

if (connectionManager!.getConnection(targetId) !== conn) {
await portForwardManager!.removeForwardAndWait(entry.id)
return
return false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Stability & Availability · Quick win

🔎 Supported by static analysis

Script executed:

#!/bin/bash
# Inspect generation changes and the forward manager's transport binding.
ast-grep outline src/main/ssh/connection/ssh-connection.ts --match 'getConnectGeneration|attemptConnect|reconnect' --view expanded
fd -i 'ssh-port-forward-manager' src/main/ssh src/main/ipc/ssh -e ts
rg -n -C 7 '\b(addForward|closeStaleForwards)\s*\(|connectGeneration\s*(\+\+|=)' src/main/ssh src/main/ipc/ssh

Repository: stablyai/orca

Length of output: 41481


Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- persistence ---'
nl -ba src/main/ipc/ssh/ssh-port-forward-persistence.ts | sed -n '1,145p'
printf '%s\n' '--- port-forward manager ---'
nl -ba src/main/ssh/port-forwarding/ssh-port-forward.ts | sed -n '1,285p'
printf '%s\n' '--- connection client/generation declarations and uses ---'
rg -n -C 8 -F -e 'connectGeneration' -e 'getClient()' -e 'getConnect' -e 'setClient' -e 'sshClient' src/main/ssh/connection src/main/ssh | head -n 260
printf '%s\n' '--- reconnect replacement and connection manager identity ---'
rg -n -C 8 -F -e 'new SshConnection' -e 'setConnection' -e 'connections.set' -e 'replace' -e 'attemptConnect' src/main/ssh/connection src/main/ssh/connection-manager.ts src/main/ssh 2>/dev/null | head -n 320

Repository: stablyai/orca

Length of output: 41868


Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- SshConnection public lifecycle and reconnect ---'
nl -ba src/main/ssh/connection/ssh-connection.ts | sed -n '280,340p;430,500p;720,825p;1000,1085p'
printf '%s\n' '--- reconnect owner ---'
nl -ba src/main/ssh/connection/ssh-connection-reconnect.ts | sed -n '1,190p'
printf '%s\n' '--- connection manager lifecycle ---'
rg -n -C 10 -F -e 'new SshConnection' -e 'reconnect' -e 'disconnectConnection' -e 'getConnection(targetId)' src/main/ssh/connection/ssh-connection-manager.ts src/main/ssh/connection
printf '%s\n' '--- SSH2 provider ---'
nl -ba src/main/ssh/port-forwarding/ssh2-port-forward-provider.ts | sed -n '1,260p'
printf '%s\n' '--- system SSH provider ---'
nl -ba src/main/ssh/port-forwarding/system-ssh-port-forward-provider.ts | sed -n '1,220p'
printf '%s\n' '--- provider type and isLive implementations ---'
nl -ba src/main/ssh/port-forwarding/ssh-port-forward-provider.ts | sed -n '1,180p'
rg -n -C 8 -F 'isLive' src/main/ssh/port-forwarding

Repository: stablyai/orca

Length of output: 45663


Script executed:

#!/bin/bash
set -e
rg -n -C 5 -F -e 'getConnectGeneration' -e 'currentGeneration' -e 'closeTransportsForReconnect' src/main/ssh/connection/ssh-connection.ts

Repository: stablyai/orca

Length of output: 1522


Detect an in-place reconnect during restoration.

SshConnection can replace its SSH client while retaining the same connection object. If this occurs while addForward awaits, the identity checks pass, but the forward remains bound to the previous client and is persisted. Capture getConnectGeneration() before restoration and reject the restore when the generation changes after an awaited operation.

Suggested fix
   const conn = connectionManager!.getConnection(targetId)
   if (!conn) {
     return false
   }
+  const connectGeneration = conn.getConnectGeneration()
   // Replaced transports can leave forwards holding local ports but reaching nothing.
   await portForwardManager!.closeStaleForwards(targetId, conn)
+  if (
+    connectionManager!.getConnection(targetId) !== conn ||
+    conn.getConnectGeneration() !== connectGeneration
+  ) {
+    return false
+  }
   const live = new Set(portForwardManager!.listForwards(targetId).map(forwardKey))
@@
-    if (connectionManager!.getConnection(targetId) !== conn) {
+    if (
+      connectionManager!.getConnection(targetId) !== conn ||
+      conn.getConnectGeneration() !== connectGeneration
+    ) {
       return false
     }
@@
-      if (connectionManager!.getConnection(targetId) !== conn) {
+      if (
+        connectionManager!.getConnection(targetId) !== conn ||
+        conn.getConnectGeneration() !== connectGeneration
+      ) {
         await portForwardManager!.removeForwardAndWait(entry.id)
         return false
       }

A restore that ran mid-edit could reopen the mapping the edit was
replacing and save both. Every add, edit, remove and restore for a host now
runs in order through the same queue.
…ling

Each runtime call triggers a restore over the managed tunnel, and with
system SSH every attempt is a new login. Restores on the same transport
now run at most once per 30 s, a forward that keeps failing waits longer
each time, and teardown forgets both. A tunnel with no status yet counts
only outside a connect; a remove re-checks its forward inside the queue;
drop a circular listener test and the plain restart e2e case.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c73e1bb7-037d-4ff6-a49b-ab50e56dc86e
Commits

Reviewing files that changed from the base of the PR and between f05cd6d and 186d62e.

Files selected for processing: 9
  • src/main/ipc/ssh/ssh-managed-host-port-forward-lifecycle.test.ts
  • src/main/ipc/ssh/ssh-managed-port-forward-restore.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.test.ts
  • src/main/ipc/ssh/ssh-port-forward-handlers.ts
  • src/main/ipc/ssh/ssh-port-forward-persistence.ts
  • src/main/ipc/ssh/ssh-port-forward-restore.test.ts
  • src/main/ipc/ssh/ssh-port-forward-unrestored.ts
  • src/main/ipc/ssh/ssh-session-teardown.ts
  • tests/e2e/ssh-orcad-workspace-tools.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Port Forwarding Lost on Restart

1 participant