Quiet disconnected SSH polling

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-13 02:53:42 -07:00
co-authored by Orca
parent 0fe7b4d41b
commit b97e48507e
6 changed files with 88 additions and 1 deletions
@@ -140,6 +140,10 @@ Done means: Local, SSH, remote server, and future cloud VMs share one Host model
- [~] Integration-test Linear/Jira with explicit source account selection.
- [x] Integration-test automations on local host, SSH host, and remote server where supported.
- [x] Integration-test disconnected host UX for Tasks, New Workspace modal, sidebar, and automations.
- 2026-06-13 follow-up: verified patched main IPC against a live Docker SSH target.
Connected SSH PTY inspection returned `bash`; after disconnect, PTY inspection returned
`null`/`false` and remote agent detection returned `[]` without repeated main-process
handler errors.
- [ ] Integration-test new client with older remote server and older client with newer server where practical.
- [x] Electron-test Add Project flows: browse folder, clone URL, and create project from scratch on local and SSH hosts.
- [x] Electron-test workspace options and sidebar host filtering.
@@ -273,6 +273,12 @@
Docker SSH target through Orca IPC, added an SSH project, created an SSH worktree, tested
disconnected SSH UX, opened Tasks, opened Automations, and captured screenshots below.
</li>
<li class="pass">
Follow-up disconnected SSH validation launched a patched second Electron instance,
connected a Docker SSH PTY, observed foreground process <code>bash</code>, disconnected
the host, then verified PTY inspection returned <code>null</code>/<code>false</code>
and remote agent detection returned <code>[]</code> without repeated handler errors.
</li>
<li class="pass">
Focused locked-SSH Add Project regression test passed:
<code>src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx</code>,
+25
View File
@@ -589,6 +589,31 @@ describe('preflight', () => {
})
})
it('returns no remote agents when the SSH connection is unavailable', async () => {
getActiveMultiplexerMock.mockReturnValue(null)
registerPreflightHandlers()
await expect(
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
).resolves.toEqual([])
})
it('returns no remote agents when the SSH connection is disposed', async () => {
const request = vi.fn()
getActiveMultiplexerMock.mockReturnValue({
isDisposed: () => true,
request
})
registerPreflightHandlers()
await expect(
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
).resolves.toEqual([])
expect(request).not.toHaveBeenCalled()
})
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
+3 -1
View File
@@ -248,7 +248,9 @@ export async function refreshShellPathAndDetectAgents(
export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> {
const mux = getActiveMultiplexer(args.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`No active SSH connection for "${args.connectionId}"`)
// Why: remote agent detection is passive UI polling. A disconnected host has
// no detectable agents until reconnect, but should not spam IPC errors.
return []
}
const result = (await mux.request('preflight.detectAgents', {
commands: KNOWN_AGENT_COMMANDS
+37
View File
@@ -2831,6 +2831,43 @@ describe('registerPtyHandlers', () => {
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated')
})
it('returns idle process inspection results for detached SSH PTYs without a provider', async () => {
const provider = {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(mainWindow as never)
setPtyOwnership('remote-pty', 'ssh-1')
unregisterSshPtyProvider('ssh-1')
await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe(
false
)
await expect(
handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
expect(provider.hasChildProcesses).not.toHaveBeenCalled()
expect(provider.getForegroundProcess).not.toHaveBeenCalled()
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
registerSshPtyProvider('ssh-1', {
+13
View File
@@ -226,6 +226,13 @@ function getProviderForPty(ptyId: string): IPtyProvider {
return getProvider(connectionId)
}
function hasPtyProviderForInspection(ptyId: string): boolean {
// Why: process inspection is background polling; disconnected SSH hosts should
// read as idle instead of surfacing repeated IPC errors.
const connectionId = ptyOwnership.get(ptyId)
return connectionId == null || sshProviders.has(connectionId)
}
function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string {
return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId
}
@@ -2801,6 +2808,9 @@ export function registerPtyHandlers(
ipcMain.handle(
'pty:hasChildProcesses',
async (_event, args: { id: string }): Promise<boolean> => {
if (!hasPtyProviderForInspection(args.id)) {
return false
}
return getProviderForPty(args.id).hasChildProcesses(args.id)
}
)
@@ -2808,6 +2818,9 @@ export function registerPtyHandlers(
ipcMain.handle(
'pty:getForegroundProcess',
async (_event, args: { id: string }): Promise<string | null> => {
if (!hasPtyProviderForInspection(args.id)) {
return null
}
return getProviderForPty(args.id).getForegroundProcess(args.id)
}
)