diff --git a/docs/ssh-handler-reregistration-port-forwards.md b/docs/ssh-handler-reregistration-port-forwards.md new file mode 100644 index 00000000000..f45e0120151 --- /dev/null +++ b/docs/ssh-handler-reregistration-port-forwards.md @@ -0,0 +1,243 @@ +# 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. diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 570ff42f143..b3ac052224f 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -15,7 +15,10 @@ const { mockPtyProvider, mockFsProvider, mockGitProvider, - mockPortForwardManager + mockPortForwardManager, + mockPortScannerCallbacks, + mockNextConnectionManagers, + mockNextPortForwardManagers } = vi.hoisted(() => ({ handleMock: vi.fn(), powerMonitorOffMock: vi.fn(), @@ -34,7 +37,9 @@ const { reconnect: vi.fn(), getConnection: vi.fn(), getState: vi.fn(), - disconnectAll: vi.fn() + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } }, mockDeployAndLaunchRelay: vi.fn(), mockForceStopRelayForTarget: vi.fn(), @@ -57,11 +62,15 @@ const { mockGitProvider: {}, mockPortForwardManager: { addForward: vi.fn(), + updateForward: vi.fn(), removeForward: vi.fn(), listForwards: vi.fn().mockReturnValue([]), removeAllForwards: vi.fn(), dispose: vi.fn() - } + }, + mockPortScannerCallbacks: new Map(), + mockNextConnectionManagers: [] as unknown[], + mockNextPortForwardManagers: [] as unknown[] })) vi.mock('electron', () => ({ @@ -88,8 +97,14 @@ vi.mock('../ssh/ssh-connection-store', () => ({ vi.mock('../ssh/ssh-connection', () => ({ SshConnectionManager: class MockSshConnectionManager { - constructor() { - return mockConnectionManager + constructor(callbacks: unknown) { + const manager = (mockNextConnectionManagers.shift() ?? + mockConnectionManager) as typeof mockConnectionManager + manager.callbacksRef.current = callbacks + manager.setCallbacks.mockImplementation((nextCallbacks: unknown) => { + manager.callbacksRef.current = nextCallbacks + }) + return manager } } })) @@ -161,12 +176,26 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../ssh/ssh-port-forward', () => ({ SshPortForwardManager: class MockPortForwardManager { constructor() { - return mockPortForwardManager + return mockNextPortForwardManagers.shift() ?? mockPortForwardManager } } })) -import { registerSshHandlers } from './ssh' +vi.mock('../ssh/ssh-port-scanner', () => ({ + PortScanner: class MockPortScanner { + startScanning(targetId: string, _mux: unknown, onChanged: unknown) { + mockPortScannerCallbacks.set(targetId, onChanged) + } + getDetectedPorts() { + return [] + } + stopScanning(targetId: string) { + mockPortScannerCallbacks.delete(targetId) + } + } +})) + +import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshTarget } from '../../shared/ssh-types' import { clearProviderPtyState, @@ -188,9 +217,35 @@ describe('SSH IPC handlers', () => { isDestroyed: () => false, webContents: { send: vi.fn() } } + const createMockWindow = () => ({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + const createConnectionManagerMock = () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + reconnect: vi.fn(), + getConnection: vi.fn(), + getState: vi.fn(), + disconnectAll: vi.fn(), + setCallbacks: vi.fn(), + callbacksRef: { current: null as unknown } + }) + const createPortForwardManagerMock = () => ({ + addForward: vi.fn(), + updateForward: vi.fn(), + removeForward: vi.fn(), + listForwards: vi.fn().mockReturnValue([]), + removeAllForwards: vi.fn(), + dispose: vi.fn() + }) - beforeEach(() => { + beforeEach(async () => { + await resetSshHandlerStateForTests() handlers.clear() + mockNextConnectionManagers.length = 0 + mockNextPortForwardManagers.length = 0 + mockPortScannerCallbacks.clear() handleMock.mockReset() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { handlers.set(channel, handler) @@ -213,6 +268,8 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getConnection.mockReset() mockConnectionManager.getState.mockReset() mockConnectionManager.disconnectAll.mockReset() + mockConnectionManager.setCallbacks.mockReset() + mockConnectionManager.callbacksRef.current = null mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ @@ -228,6 +285,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.shutdown.mockReset() mockPortForwardManager.addForward.mockReset() + mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() mockPortForwardManager.listForwards.mockReset().mockReturnValue([]) mockPortForwardManager.removeAllForwards.mockReset() @@ -437,6 +495,64 @@ describe('SSH IPC handlers', () => { } }) + it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { + vi.useFakeTimers() + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + try { + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as + | ((reason: 'shutdown' | 'connection_lost') => void) + | undefined + + onDispose?.('connection_lost') + + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel lost. Reconnecting...', + reconnectAttempt: 1 + }) + + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockDeployAndLaunchRelay).toHaveBeenCalled() + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } finally { + vi.useRealTimers() + } + }) + it('forwards remote PTY events into the runtime', async () => { const runtime = { onPtyData: vi.fn(), @@ -474,6 +590,258 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('preserves active port forwards and live connections across handler re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + const forward = { + id: 'pf-1', + connectionId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + } + const updatedForward = { ...forward, remotePort: 3001 } + const newForward = { ...forward, id: 'pf-2', localPort: 4101 } + const connectedState = { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue(connectedState) + mockPortForwardManager.addForward + .mockResolvedValueOnce(forward) + .mockResolvedValueOnce(newForward) + mockPortForwardManager.updateForward.mockResolvedValue(updatedForward) + mockPortForwardManager.removeForward.mockReturnValue(updatedForward) + mockPortForwardManager.listForwards.mockReturnValue([forward]) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(getSshConnectionManager()).toBe(mockConnectionManager) + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + mockDeployAndLaunchRelay.mockClear() + mockPortForwardManager.removeAllForwards.mockClear() + + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual( + connectedState + ) + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ + forward + ]) + + await handlers.get('ssh:updatePortForward')!(null, { + id: 'pf-1', + targetId: 'ssh-1', + localPort: 4100, + remoteHost: '127.0.0.1', + remotePort: 3001, + label: 'app' + }) + expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith( + 'pf-1', + conn, + 4100, + '127.0.0.1', + 3001, + 'app' + ) + + expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual( + updatedForward + ) + await handlers.get('ssh:addPortForward')!(null, { + targetId: 'ssh-1', + localPort: 4101, + remoteHost: '127.0.0.1', + remotePort: 3000, + label: 'app' + }) + expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith( + 'ssh-1', + conn, + 4101, + '127.0.0.1', + 3000, + 'app' + ) + expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled() + expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled() + }) + + it('disconnects the original session and releases original forwards after re-registration', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockClear() + mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined) + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + registerSshHandlers(mockStore as never, () => createMockWindow() as never) + await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(replacementPortForwardManager.removeAllForwards).not.toHaveBeenCalled() + expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled() + }) + + it('refreshes live session callbacks to the newest window, store, and runtime', async () => { + const firstWindow = createMockWindow() + const secondWindow = createMockWindow() + const firstRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + const secondRuntime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn() + } + registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as + | ((payload: { id: string; data: string }) => void) + | undefined + const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as + | ((payload: { id: string; code: number }) => void) + | undefined + const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as + | ((targetId: string, ports: unknown[], platform: string) => void) + | undefined + firstWindow.webContents.send.mockClear() + secondWindow.webContents.send.mockClear() + + registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never) + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: unknown) => void + } + + callbacks.onStateChange('ssh-1', { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + }) + onData?.({ id: 'remote-pty', data: 'hello' }) + onExit?.({ id: 'remote-pty', code: 9 }) + onDetectedPorts?.( + 'ssh-1', + [{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }], + 'linux-x64' + ) + + expect(firstWindow.webContents.send).not.toHaveBeenCalled() + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'error', + error: 'network down', + reconnectAttempt: 0 + } + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'remote-pty', data: 'hello' }) + ) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'remote-pty', + code: 9 + }) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', { + targetId: 'ssh-1', + ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) + }) + expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9) + expect(firstRuntime.onPtyData).not.toHaveBeenCalled() + expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + 'terminated' + ) + }) + + it('re-registers without replacing managers when no targets are connected', () => { + const replacementConnectionManager = createConnectionManagerMock() + const replacementPortForwardManager = createPortForwardManagerMock() + mockNextConnectionManagers.push(replacementConnectionManager) + mockNextPortForwardManagers.push(replacementPortForwardManager) + + const result = registerSshHandlers(mockStore as never, () => createMockWindow() as never) + + expect(result.connectionManager).toBe(mockConnectionManager) + expect(replacementConnectionManager.setCallbacks).not.toHaveBeenCalled() + expect(replacementPortForwardManager.dispose).not.toHaveBeenCalled() + expect(mockNextConnectionManagers).toHaveLength(1) + expect(mockNextPortForwardManagers).toHaveLength(1) + }) + it('ssh:disconnect calls connection manager', async () => { mockConnectionManager.disconnect.mockResolvedValue(undefined) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ecf88092d8e..3497fd9f99f 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -45,6 +45,37 @@ let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined let persistedStore: Store | null = null let advertisedUrlWatcherUnsubscribe: (() => void) | null = null let powerMonitorUnsubscribe: (() => void) | null = null +let currentGetMainWindow: () => BrowserWindow | null = () => null +let currentRuntime: OrcaRuntimeService | undefined + +const SSH_IPC_CHANNELS = [ + 'ssh:listTargets', + 'ssh:addTarget', + 'ssh:updateTarget', + 'ssh:removeTarget', + 'ssh:importConfig', + 'ssh:connect', + 'ssh:disconnect', + 'ssh:terminateSessions', + 'ssh:resetRelay', + 'ssh:getState', + 'ssh:needsPassphrasePrompt', + 'ssh:testConnection', + 'ssh:addPortForward', + 'ssh:updatePortForward', + 'ssh:removePortForward', + 'ssh:listPortForwards', + 'ssh:listDetectedPorts' +] as const + +// Why: connection callbacks are process-lifetime; keeping this set outside +// registerSshHandlers prevents in-flight connects from splitting credential +// tracking when a BrowserWindow is recreated. +const credentialRequestedForTarget = new Set() + +function getCurrentMainWindow(): BrowserWindow | null { + return currentGetMainWindow() +} export async function connectRegisteredSshTarget(targetId: string): Promise { if (!registeredConnectSshTarget) { @@ -340,51 +371,11 @@ function registerPowerMonitorReconnect(): void { } } -export function registerSshHandlers( - store: Store, - getMainWindow: () => BrowserWindow | null, - runtime?: OrcaRuntimeService -): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { - // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls - // this function. ipcMain.handle() throws if a handler is already registered, - // so we must remove any prior handlers before re-registering. - for (const ch of [ - 'ssh:listTargets', - 'ssh:addTarget', - 'ssh:updateTarget', - 'ssh:removeTarget', - 'ssh:importConfig', - 'ssh:connect', - 'ssh:disconnect', - 'ssh:terminateSessions', - 'ssh:resetRelay', - 'ssh:getState', - 'ssh:needsPassphrasePrompt', - 'ssh:testConnection', - 'ssh:addPortForward', - 'ssh:updatePortForward', - 'ssh:removePortForward', - 'ssh:listPortForwards', - 'ssh:listDetectedPorts' - ]) { - ipcMain.removeHandler(ch) - } - - sshStore = new SshConnectionStore(store) - persistedStore = store - registerAdvertisedUrlRefresh(getMainWindow) - - registerCredentialHandler(getMainWindow) - - // Why: tracks whether a credential prompt was triggered during the current - // ssh:connect call. Used to set lastRequiredPassphrase on the target so - // startup reconnect can defer passphrase-protected targets to tab focus. - const credentialRequestedForTarget = new Set() - - const callbacks: SshConnectionCallbacks = { +function createSshConnectionCallbacks(): SshConnectionCallbacks { + return { onCredentialRequest: (targetId, kind, detail) => { credentialRequestedForTarget.add(targetId) - return requestCredential(getMainWindow, targetId, kind, detail) + return requestCredential(getCurrentMainWindow, targetId, kind, detail) }, onStateChange: (targetId: string, state: SshConnectionState) => { if (testingTargets.has(targetId)) { @@ -407,7 +398,7 @@ export function registerSshHandlers( // Why: SSH is connected before the relay providers are rebuilt. Keep // renderer actions gated until SshRelaySession reaches ready again. publishRelayOverride( - getMainWindow, + getCurrentMainWindow, targetId, 'reconnecting', 'Relay channel reconnecting...', @@ -415,7 +406,7 @@ export function registerSshHandlers( ) } else { clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, state) + broadcastSshState(getCurrentMainWindow, targetId, state) } if (!session) { @@ -434,9 +425,159 @@ export function registerSshHandlers( } } } +} - connectionManager = new SshConnectionManager(callbacks) - portForwardManager = new SshPortForwardManager() +function broadcastDetectedPortsFromCurrentWindow( + targetId: string, + ports: DetectedPort[], + _platform: string +): void { + broadcastDetectedPorts(getCurrentMainWindow, targetId, ports) +} + +function configureRelaySessionCallbacks(session: SshRelaySession): void { + session.setOnTerminalRelayError((tid, err) => { + clearRelayLostBackoff(tid) + console.warn( + `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` + ) + publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0) + }) + + session.setOnRelayLost((tid) => { + const s = activeSessions.get(tid) + if (!s) { + return + } + const c = connectionManager?.getConnection(tid) + if (!c) { + return + } + const t = sshStore?.getTarget(tid) + + // Why: bounded exponential backoff. Without this, a remote-side bug + // that closes every fresh --connect channel turns into an infinite + // tight loop spawning relay deploys until the user force-quits. + const state = relayLostBackoff.get(tid) ?? { + attempts: 0, + lastAttemptStartedAt: 0, + pendingTimer: null + } + if (state.pendingTimer) { + return + } + if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + console.warn( + `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` + ) + relayLostBackoff.delete(tid) + // Why: surface the failure so the renderer can prompt the user. + // A still-live SSH connection with a dead relay is otherwise an + // invisible failure — typing in remote terminals just stops working. + publishRelayOverride( + getCurrentMainWindow, + tid, + 'error', + 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', + 0 + ) + return + } + const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) + state.attempts += 1 + publishRelayOverride( + getCurrentMainWindow, + tid, + 'reconnecting', + 'Relay channel lost. Reconnecting...', + state.attempts + ) + state.pendingTimer = setTimeout(() => { + state.pendingTimer = null + state.lastAttemptStartedAt = Date.now() + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + return + } + void s.reconnect(liveConn, relayGracePeriodForTarget(t)) + }, delay) + relayLostBackoff.set(tid, state) + console.warn( + `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` + ) + }) + + // Why: fires after both establish() and reconnect() reach 'ready'. + // Re-creates persisted port forwards so they survive app restarts + // and network blips without manual re-configuration. + session.setOnReady((tid) => { + const state = relayLostBackoff.get(tid) + if (state) { + const stabilized = + state.lastAttemptStartedAt === 0 || + Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS + if (stabilized) { + relayLostBackoff.delete(tid) + } + } + clearRelayStateOverride(tid) + if (!testingTargets.has(tid)) { + broadcastSshState(getCurrentMainWindow, tid, { + targetId: tid, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } + void restorePortForwards(tid, getCurrentMainWindow) + }) +} + +function refreshActiveRelaySessions(): void { + if (!persistedStore || !portForwardManager) { + return + } + for (const session of activeSessions.values()) { + session.refreshEnvironment( + getCurrentMainWindow, + persistedStore, + portForwardManager, + currentRuntime, + broadcastDetectedPortsFromCurrentWindow + ) + configureRelaySessionCallbacks(session) + } +} + +export function registerSshHandlers( + store: Store, + getMainWindow: () => BrowserWindow | null, + runtime?: OrcaRuntimeService +): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } { + // Why: on macOS, app re-activation creates a new BrowserWindow and re-calls + // this function. ipcMain.handle() throws if a handler is already registered, + // so we must remove any prior handlers before re-registering. + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + + currentGetMainWindow = getMainWindow + currentRuntime = runtime + sshStore = new SshConnectionStore(store) + persistedStore = store + registerAdvertisedUrlRefresh(getCurrentMainWindow) + + registerCredentialHandler(getCurrentMainWindow) + + const callbacks = createSshConnectionCallbacks() + if (connectionManager) { + connectionManager.setCallbacks(callbacks) + } else { + connectionManager = new SshConnectionManager(callbacks) + } + portForwardManager ??= new SshPortForwardManager() + refreshActiveRelaySessions() registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) @@ -475,7 +616,7 @@ export function registerSshHandlers( `[ssh] Failed to disconnect removed target ${args.id}: ${err instanceof Error ? err.message : String(err)}` ) } - store.removeSshRemotePtyLeases(args.id) + persistedStore!.removeSshRemotePtyLeases(args.id) sshStore!.removeTarget(args.id) }) @@ -516,17 +657,35 @@ export function registerSshHandlers( }) async function doConnect(targetId: string): Promise { - clearRelayStateOverride(targetId) const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) } + const existingSession = activeSessions.get(targetId) + const existingState = connectionManager!.getState(targetId) + const existingMux = existingSession?.getMux() + if ( + existingSession?.getState() === 'ready' && + existingState?.status === 'connected' && + connectionManager!.getConnection(targetId) && + existingMux && + !existingMux.isDisposed() && + !relayStateOverrides.has(targetId) && + !relayLostBackoff.has(targetId) + ) { + // Why: BrowserWindow reactivation reruns renderer startup, which calls + // ssh:connect for already-live targets. Treat that as a refresh instead + // of tearing down the relay and stranding active port forwards. + broadcastSshState(getCurrentMainWindow, targetId, existingState) + return existingState + } + + clearRelayStateOverride(targetId) let conn // Why: dispose any existing session to avoid leaking the old multiplexer, // providers, and timers. This handles double-connect (user clicks connect // while already connected) and reconnect-after-error. - const existingSession = activeSessions.get(targetId) if (existingSession) { // Why: await port teardown before disposing so the OS fully releases // local ports. Without this, restorePortForwards in the new session @@ -542,14 +701,13 @@ export function registerSshHandlers( // state and knows not to trigger reconnect logic. const session = new SshRelaySession( targetId, - getMainWindow, - store, + getCurrentMainWindow, + persistedStore!, portForwardManager!, - runtime, - (tid, ports, _platform) => { - broadcastDetectedPorts(getMainWindow, tid, ports) - } + currentRuntime, + broadcastDetectedPortsFromCurrentWindow ) + configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) try { @@ -569,7 +727,7 @@ export function registerSshHandlers( activeSessions.delete(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) - broadcastSshState(getMainWindow, targetId, { + broadcastSshState(getCurrentMainWindow, targetId, { targetId, status, error: errObj.message, @@ -587,129 +745,13 @@ export function registerSshHandlers( reconnectAttempt: 0 }) - // Why: the relay exec channel can close independently of the SSH - // connection (e.g. --connect bridge exits, relay process crashes). - // When that happens, the mux is disposed but onStateChange never - // fires because the SSH connection is still alive. This callback - // triggers session.reconnect() using the live SSH connection. - // Set before establish() so the callback is in place if the relay - // dies during the deploy/connect sequence. - // Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means - // the local client and remote daemon are at different code versions — - // no amount of backoff will reconcile them. Skip the relay-lost loop - // entirely and surface a terminal "please reconnect manually" error. - session.setOnTerminalRelayError((tid, err) => { - clearRelayLostBackoff(tid) - console.warn( - `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` - ) - publishRelayOverride(getMainWindow, tid, 'error', err.message, 0) - }) - - session.setOnRelayLost((tid) => { - const s = activeSessions.get(tid) - if (!s) { - return - } - const c = connectionManager?.getConnection(tid) - if (!c) { - return - } - const t = sshStore?.getTarget(tid) - - // Why: bounded exponential backoff. Without this, a remote-side bug - // that closes every fresh --connect channel turns into an infinite - // tight loop spawning relay deploys until the user force-quits. - const state = relayLostBackoff.get(tid) ?? { - attempts: 0, - lastAttemptStartedAt: 0, - pendingTimer: null - } - if (state.pendingTimer) { - // A retry is already scheduled — coalesce this burst. - return - } - if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { - console.warn( - `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` - ) - relayLostBackoff.delete(tid) - // Why: surface the failure so the renderer can prompt the user. - // A still-live SSH connection with a dead relay is otherwise an - // invisible failure — typing in remote terminals just stops working. - publishRelayOverride( - getMainWindow, - tid, - 'error', - 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - 0 - ) - return - } - const delay = Math.min( - RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, - RELAY_LOST_MAX_DELAY_MS - ) - state.attempts += 1 - publishRelayOverride( - getMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - state.pendingTimer = setTimeout(() => { - state.pendingTimer = null - state.lastAttemptStartedAt = Date.now() - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - return - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - }, delay) - relayLostBackoff.set(tid, state) - console.warn( - `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` - ) - }) - - // Why: fires after both establish() and reconnect() reach 'ready'. - // Re-creates persisted port forwards so they survive app restarts - // and network blips without manual re-configuration. We also clear - // the relay-lost backoff state so a subsequent genuine drop starts - // from a fresh attempt counter — but only if the session had a chance - // to stabilize, otherwise rapid `ready → lost → ready → lost` flaps - // would silently keep retrying forever. - session.setOnReady((tid) => { - const state = relayLostBackoff.get(tid) - if (state) { - const stabilized = - state.lastAttemptStartedAt === 0 || - Date.now() - state.lastAttemptStartedAt >= RELAY_LOST_STABILIZED_MS - if (stabilized) { - relayLostBackoff.delete(tid) - } - } - clearRelayStateOverride(tid) - if (!testingTargets.has(tid)) { - broadcastSshState(getMainWindow, tid, { - targetId: tid, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - } - void restorePortForwards(tid, getMainWindow) - }) - await session.establish(conn, relayGracePeriodForTarget(target)) // Why: we manually pushed `deploying-relay` above, so the renderer's // state is stuck there. Send `connected` directly to the renderer // instead of going through callbacks.onStateChange, which would // trigger the reconnection logic. - const win = getMainWindow() + const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { clearRelayStateOverride(targetId) win.webContents.send('ssh:state-changed', { @@ -759,7 +801,7 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { const session = activeSessions.get(args.targetId) const provider = getSshPtyProvider(args.targetId) - const leasedIds = store + const leasedIds = persistedStore! .getSshRemotePtyLeases(args.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') .map((lease) => lease.ptyId) @@ -803,7 +845,7 @@ export function registerSshHandlers( } clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) - store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the @@ -848,10 +890,10 @@ export function registerSshHandlers( await forceStopRelayForTarget(conn, targetId) } finally { const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of store.getSshRemotePtyLeases(targetId)) { + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && lease.state !== 'expired') { ptyIds.add(lease.ptyId) - store.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') } } // Why: reset force-kills the remote relay daemon, so every local PTY @@ -998,7 +1040,7 @@ export function registerSshHandlers( args.label ) persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) return entry } ) @@ -1030,14 +1072,14 @@ export function registerSshHandlers( args.label ) persistPortForwards(entry.connectionId) - broadcastPortForwards(getMainWindow, entry.connectionId) + broadcastPortForwards(getCurrentMainWindow, entry.connectionId) return entry } catch (err) { // Why: if the edit failed (and rollback may also have failed), // sync the renderer with the actual runtime state so it doesn't // show a forward that no longer exists. persistPortForwards(args.targetId) - broadcastPortForwards(getMainWindow, args.targetId) + broadcastPortForwards(getCurrentMainWindow, args.targetId) throw err } } @@ -1047,7 +1089,7 @@ export function registerSshHandlers( const removed = portForwardManager!.removeForward(args.id) if (removed) { persistPortForwards(removed.connectionId) - broadcastPortForwards(getMainWindow, removed.connectionId) + broadcastPortForwards(getCurrentMainWindow, removed.connectionId) } return removed }) @@ -1076,6 +1118,41 @@ export function getSshConnectionManager(): SshConnectionManager | null { return connectionManager } +export async function resetSshHandlerStateForTests(): Promise { + advertisedUrlWatcherUnsubscribe?.() + advertisedUrlWatcherUnsubscribe = null + powerMonitorUnsubscribe?.() + powerMonitorUnsubscribe = null + for (const ch of SSH_IPC_CHANNELS) { + ipcMain.removeHandler(ch) + } + ipcMain.removeHandler('ssh:submitCredential') + + for (const session of activeSessions.values()) { + session.dispose() + } + activeSessions.clear() + for (const targetId of relayLostBackoff.keys()) { + clearRelayLostBackoff(targetId) + } + relayStateOverrides.clear() + connectInFlight.clear() + resetRelayInFlight.clear() + testingTargets.clear() + credentialRequestedForTarget.clear() + + await connectionManager?.disconnectAll() + portForwardManager?.dispose() + connectionManager = null + portForwardManager = null + sshStore = null + persistedStore = null + registeredConnectSshTarget = null + registeredGetSshState = null + currentGetMainWindow = () => null + currentRuntime = undefined +} + export function getSshConnectionStore(): SshConnectionStore | null { return sshStore } diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index b5eb04bf28d..d568d439409 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -18,6 +18,13 @@ export class SshConnectionManager { this.callbacks = callbacks } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + for (const connection of this.connections.values()) { + connection.setCallbacks(callbacks) + } + } + async connect(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 259e094bc9f..989010712fe 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -70,6 +70,10 @@ export class SshConnection { return { ...this.target } } + setCallbacks(callbacks: SshConnectionCallbacks): void { + this.callbacks = callbacks + } + // Why: exposes whether a passphrase/password is already cached in-memory for // this connection. Used by ssh:needsPassphrasePrompt so callers can decide // whether a manual-reconnect will prompt or go through silently. Without this, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 9da2918e88a..73b72b8647b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -110,6 +110,20 @@ export class SshRelaySession { ) => void ) {} + refreshEnvironment( + getMainWindow: () => BrowserWindow | null, + store: Store, + portForwardManager: SshPortForwardManager, + runtime?: OrcaRuntimeService, + onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void + ): void { + this.getMainWindow = getMainWindow + this.store = store + this.portForwardManager = portForwardManager + this.runtime = runtime + this.onDetectedPortsChanged = onDetectedPortsChanged + } + setOnRelayLost(cb: (targetId: string) => void): void { this._onRelayLost = cb } @@ -783,10 +797,9 @@ export class SshRelaySession { } private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { - const getWin = this.getMainWindow ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:data', { ...payload, @@ -795,7 +808,7 @@ export class SshRelaySession { } }) ptyProvider.onReplay((payload) => { - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', payload) } @@ -806,7 +819,7 @@ export class SshRelaySession { deletePtyOwnership(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) - const win = getWin() + const win = this.getMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) }