mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix SSH handler re-registration port forwards (#2963)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
+376
-8
@@ -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<string, unknown>(),
|
||||
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)
|
||||
|
||||
|
||||
+259
-182
@@ -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<string>()
|
||||
|
||||
function getCurrentMainWindow(): BrowserWindow | null {
|
||||
return currentGetMainWindow()
|
||||
}
|
||||
|
||||
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
|
||||
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<string>()
|
||||
|
||||
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<SshConnectionState> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<SshConnection> {
|
||||
const existing = this.connections.get(target.id)
|
||||
if (existing?.getState().status === 'connected') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user