244 lines
12 KiB
Markdown
244 lines
12 KiB
Markdown
# SSH Handler Re-registration Port Forwards
|
|
|
|
## Problem
|
|
|
|
Issue #2932 reported that macOS window reactivation can re-run
|
|
`attachMainWindowServices`, which calls `registerSshHandlers` again
|
|
([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)).
|
|
|
|
Before this change, `registerSshHandlers` removed and re-added IPC handlers but
|
|
also replaced the module-level `connectionManager` and `portForwardManager`
|
|
([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438),
|
|
[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)).
|
|
`activeSessions` remains module-global
|
|
([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)),
|
|
so live relay sessions kept references to the old port-forward manager while
|
|
new IPC handlers read a fresh empty one.
|
|
|
|
The visible failure is:
|
|
|
|
1. Connect an SSH target.
|
|
2. Add a local port forward.
|
|
3. Close all windows on macOS while the app process remains alive.
|
|
4. Reactivate Orca, causing SSH handlers to register again.
|
|
5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot
|
|
remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can
|
|
fail because the fresh connection manager has no live connection, and
|
|
`ssh:disconnect` does not close the old SSH connection or local listener.
|
|
Re-adding the same local port fails because the old server remains bound.
|
|
|
|
## Root Cause
|
|
|
|
SSH handler registration mixes two lifetimes:
|
|
|
|
- Process-lifetime session state: active SSH connections, relay sessions, port
|
|
listeners, relay lost backoff, reset/connect in-flight maps.
|
|
- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers.
|
|
|
|
The previous re-registration path preserved `activeSessions` but replaced the
|
|
managers that sessions and IPC handlers must share. Port-forward IPC operations
|
|
use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992),
|
|
[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047),
|
|
[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)),
|
|
and disconnect/terminate cleanup also uses that variable
|
|
([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750),
|
|
[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)).
|
|
After replacement, those operations no longer targeted the manager that owns the
|
|
live local servers. Replacing `connectionManager` also strands the live
|
|
`SshConnection` objects: existing relay sessions still hold their current
|
|
connection, but new IPC handlers and `getSshConnectionManager()` see an empty
|
|
manager.
|
|
|
|
## Non-goals
|
|
|
|
- Do not change relay protocol, remote deployment, or SSH transport behavior.
|
|
- Do not redesign port-forward persistence or enrichment.
|
|
- Do not change renderer UI.
|
|
- Do not introduce a second SSH service layer.
|
|
- Do not force-dispose live SSH sessions merely because a window was recreated.
|
|
|
|
## Design
|
|
|
|
1. Preserve process-lifetime managers across handler re-registration.
|
|
Instantiate `SshConnectionManager` and `SshPortForwardManager` only when
|
|
absent; later `registerSshHandlers` calls reuse the existing instances.
|
|
|
|
2. Refresh every live callback owner on re-registration. This is required; a
|
|
plain `connectionManager ??= new SshConnectionManager(callbacks)` is not
|
|
enough.
|
|
- `SshConnectionManager` must update callbacks used by both future and
|
|
existing `SshConnection` objects, either via explicit `setCallbacks` methods
|
|
on manager/connection or via a stable callback proxy whose implementation
|
|
is mutable.
|
|
- Existing `SshRelaySession` objects must refresh `getMainWindow`, store,
|
|
runtime, and detected-port callback references. Event handlers must call
|
|
the current callback at event time; do not capture the old `getMainWindow`
|
|
in long-lived provider callbacks.
|
|
- The credential-request tracking set must not be per-registration if live
|
|
connections can switch callbacks during an in-flight `ssh:connect`.
|
|
|
|
3. Re-register IPC handlers and dependent global listeners on every call.
|
|
`ipcMain` handlers, advertised URL refresh, credential IPC, browse handler,
|
|
and power-monitor listeners are window-registration concerns and should still
|
|
point at the latest window.
|
|
|
|
4. Preserve existing explicit teardown behavior.
|
|
`ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and
|
|
double-connect cleanup must still remove forwards through the shared manager
|
|
before detaching or disposing sessions.
|
|
|
|
5. Add regression tests in `src/main/ipc/ssh.test.ts`.
|
|
Connect a target, add a mocked port forward, call `registerSshHandlers`
|
|
again, then assert:
|
|
- `ssh:listPortForwards` still returns the original forward.
|
|
- `ssh:removePortForward` can remove the original id.
|
|
- `ssh:addPortForward`/`ssh:updatePortForward` still use the original live
|
|
connection.
|
|
- A second re-registration followed by `ssh:disconnect` still calls
|
|
`removeAllForwards` and `disconnect` on the original shared managers.
|
|
- State, credential, PTY, and detected-port callbacks from an existing live
|
|
session publish to the newest window after re-registration.
|
|
|
|
## Data Flow
|
|
|
|
- First registration:
|
|
- `registerSshHandlers(store, getWindowA)` creates store wrapper, connection
|
|
manager, port-forward manager, handlers, listeners, and current callback
|
|
environment.
|
|
- `ssh:connect` creates a relay session with the shared port-forward manager.
|
|
- `ssh:addPortForward` stores a local server in that same manager.
|
|
|
|
- Window reactivation:
|
|
- `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers.
|
|
- Existing managers are reused.
|
|
- Existing connection and relay-session callback owners are refreshed to the
|
|
latest store/runtime/window environment.
|
|
- New handlers close over `getWindowB` and call the same managers.
|
|
|
|
- Cleanup:
|
|
- `ssh:removePortForward` and `ssh:disconnect` operate on the same manager
|
|
that owns the live forward, then broadcast through the latest window.
|
|
|
|
## Edge Cases
|
|
|
|
- Re-registration while a target is connected and has active port forwards.
|
|
- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect,
|
|
or disconnect is in flight. The operation must not split credential tracking
|
|
or create a session that holds stale callbacks.
|
|
- Re-registration while no targets are connected.
|
|
- Re-registration after the store object changes. Either update existing relay
|
|
sessions to use the new store/runtime or document and test the stronger
|
|
invariant that production re-registration always passes the same process
|
|
store/runtime.
|
|
- Re-registration after the window changes. All broadcasts, credential prompts,
|
|
PTY events, detected-port events, advertised URL refreshes, relay-loss state
|
|
changes, and terminal relay errors must use the newest `getMainWindow`.
|
|
- Disconnect after re-registration must release old local ports.
|
|
- `ssh:connect` after window reactivation must be idempotent when the existing
|
|
session is already ready and healthy: return the connected state without
|
|
tearing down forwards. Explicit reset/reconnect or non-ready replacement paths
|
|
must still await old port teardown before restoring forwards.
|
|
- `getSshConnectionManager()` consumers must continue to see live connections
|
|
after re-registration.
|
|
- Test isolation must not depend on module-singleton state leaking between
|
|
tests. Add explicit reset/teardown support if preserving managers makes
|
|
`beforeEach(registerSshHandlers)` insufficient.
|
|
- SSH and relay paths must keep working for remote targets; the fix must not
|
|
assume local filesystem or local-only execution.
|
|
- Windows/Linux remain unaffected: re-registration can still happen during
|
|
development or future window lifecycles, and the fix must avoid path or
|
|
platform assumptions.
|
|
|
|
## Test Plan
|
|
|
|
- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts`
|
|
- Add regression coverage for list/remove/disconnect after handler
|
|
re-registration.
|
|
- Add coverage that add/update after re-registration uses the still-live
|
|
connection manager connection, not a fresh empty manager.
|
|
- Add coverage that existing connection/session callbacks publish to a second
|
|
mock window after re-registration.
|
|
- Add an in-flight connect or credential-request test if callback refresh uses
|
|
mutable callback objects.
|
|
- Existing connect, disconnect, reset, relay-loss, and terminate tests cover
|
|
adjacent lifecycle behavior.
|
|
- Typecheck: `pnpm typecheck`.
|
|
- Lint: `pnpm lint`.
|
|
- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if
|
|
available in the running app, add a disposable local port forward, trigger
|
|
window/service re-registration by closing and reopening the main window on
|
|
macOS, then verify the forward remains listed and removable. IPC/unit tests
|
|
are supporting evidence only; if the golden path cannot be exercised safely,
|
|
halt before PR and report the missing evidence.
|
|
|
|
## UI Quality Bar
|
|
|
|
Not UI-visible. No layout, copy, or visual styling changes are expected. The
|
|
only user-visible expectation is that existing SSH port-forward rows remain
|
|
present and actionable after window reactivation.
|
|
|
|
## Review Screenshots
|
|
|
|
1. SSH target connected with a port forward listed before re-registration.
|
|
2. Same SSH target after window reactivation, showing the same port forward
|
|
still listed.
|
|
3. Same SSH target after removing the port forward, showing it gone without an
|
|
error.
|
|
|
|
## Rollout
|
|
|
|
1. Add the focused regression test to prove the current lifecycle bug.
|
|
2. Change SSH handler registration to reuse process-lifetime managers.
|
|
3. Run the focused test, then typecheck and lint.
|
|
4. Validate in Electron against an SSH target if feasible; otherwise halt
|
|
before PR if the golden-path SSH UI cannot be exercised.
|
|
|
|
## Lightweight Eng Review
|
|
|
|
- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence
|
|
redesign is needed because the broken boundary is manager replacement during
|
|
handler re-registration.
|
|
- Architecture/data flow: process-lifetime managers stay module-level and are
|
|
reused; window-lifetime IPC handlers/listeners are refreshed; existing
|
|
connection and relay-session callback owners must also be refreshed or proxied
|
|
so live events target the current BrowserWindow.
|
|
- Failure modes covered:
|
|
- Active forwards becoming invisible after re-registration.
|
|
- `ssh:removePortForward` missing the old forward id.
|
|
- `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty
|
|
connection manager.
|
|
- `ssh:disconnect` failing to close old SSH connections and local listeners
|
|
after re-registration.
|
|
- Store/window/runtime callback refresh after re-registration.
|
|
- Re-registration during in-flight connect/reset/reconnect.
|
|
- No-session re-registration continuing to work.
|
|
- Test coverage required:
|
|
- Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across
|
|
`registerSshHandlers` calls.
|
|
- Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after
|
|
re-registration.
|
|
- Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the
|
|
newest window after re-registration.
|
|
- Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test
|
|
teardown/reset of module singletons.
|
|
- Existing lifecycle tests for reset, terminate, relay loss, and sleep remain
|
|
adjacent coverage.
|
|
- Performance/blast radius: no material startup or IPC cost. Reusing managers
|
|
avoids leaked runtime state and does not add polling, watchers, or
|
|
cross-process calls. Callback refresh is O(number of live SSH connections and
|
|
sessions) per registration, which should be tiny.
|
|
- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state
|
|
rather than changing layout or copy.
|
|
- Required review screenshots:
|
|
1. Connected SSH target with active port forward before re-registration.
|
|
2. Connected SSH target with same port forward after re-registration.
|
|
3. Connected SSH target after removing that forward.
|
|
- Feasibility: one-time manager creation is feasible only with callback refresh
|
|
for existing `SshConnection` and `SshRelaySession` instances. If that refresh
|
|
proves larger than expected, prefer a stable callback proxy over recreating
|
|
managers; do not dispose live sessions just to make callback ownership easier.
|
|
- Residual risks: Electron validation may be constrained by availability of an
|
|
existing SSH target and by avoiding live-user port collisions. If the golden
|
|
path cannot be exercised safely, stop before opening a PR and report the
|
|
missing manual evidence.
|