From dc7c41a0d2617b7145f4eaba20d0b6daa730acbb Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:16:28 +0200 Subject: [PATCH 1/5] fix(pty): timeout stalled SSH channel opens --- .../src/main/core/pty/ssh2-pty.test.ts | 55 ++++++++++++++- .../src/main/core/pty/ssh2-pty.ts | 67 +++++++++++++------ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts index 3de79a095b..31d2111e94 100644 --- a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts +++ b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { Ssh2PtySession } from './ssh2-pty'; +import { openSsh2Pty, Ssh2PtySession } from './ssh2-pty'; class FakeClientChannel extends EventEmitter { writes: string[] = []; @@ -42,4 +42,57 @@ describe('Ssh2PtySession', () => { expect(channel.closed).toBe(true); expect(exitHandler).toHaveBeenCalledWith({ exitCode: 0, signal: undefined }); }); + + it('fails SSH channel opens that never call back', async () => { + vi.useFakeTimers(); + try { + const destroy = vi.fn(); + const proxy = { + client: { destroy }, + execPty: vi.fn(), + }; + + const resultPromise = openSsh2Pty(proxy as never, { + id: 'ssh-session', + command: 'bash', + cols: 80, + rows: 24, + }); + + await vi.advanceTimersByTimeAsync(15_000); + const result = await resultPromise; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.kind).toBe('channel-open-timeout'); + expect(result.error.message).toContain('timed out'); + } + expect(destroy).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('returns a failed open when execPty throws synchronously', async () => { + const proxy = { + execPty: vi.fn(() => { + throw new Error('SSH connection is not available'); + }), + }; + + const result = await openSsh2Pty(proxy as never, { + id: 'ssh-session', + command: 'bash', + cols: 80, + rows: 24, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + kind: 'channel-open-failed', + message: 'SSH connection is not available', + }); + } + }); }); diff --git a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts index 36cf32a748..676f4ca397 100644 --- a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts +++ b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts @@ -6,10 +6,13 @@ import { normalizeSignal } from './exit-signals'; import type { Pty, PtyDimensions, PtyExitInfo } from './pty'; export type Ssh2OpenError = { - readonly kind: 'channel-open-failed'; + readonly kind: 'channel-open-failed' | 'channel-open-timeout'; readonly message: string; }; +const CHANNEL_OPEN_TIMEOUT_MS = 15_000; +const CHANNEL_OPEN_TIMEOUT_MESSAGE = `SSH channel open timed out after ${CHANNEL_OPEN_TIMEOUT_MS}ms`; + export interface Ssh2SpawnOptions extends PtyDimensions { id: string; command: string; @@ -66,25 +69,51 @@ export async function openSsh2Pty( ): Promise> { const { id, command, cols, rows } = options; return new Promise((resolve) => { - proxy.execPty( - command, - { - pty: { - term: 'xterm-256color', - cols, - rows, - // width/height in pixels — set to 0, terminal uses cols/rows instead - width: 0, - height: 0, + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + proxy.client.destroy(); + } catch {} + resolve( + err({ + kind: 'channel-open-timeout', + message: CHANNEL_OPEN_TIMEOUT_MESSAGE, + }) + ); + }, CHANNEL_OPEN_TIMEOUT_MS); + + try { + proxy.execPty( + command, + { + pty: { + term: 'xterm-256color', + cols, + rows, + // width/height in pixels — set to 0, terminal uses cols/rows instead + width: 0, + height: 0, + }, }, - }, - (e, channel) => { - if (e) { - const message = e instanceof Error ? e.message : String(e); - return resolve(err({ kind: 'channel-open-failed', message })); + (e, channel) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (e) { + const message = e instanceof Error ? e.message : String(e); + return resolve(err({ kind: 'channel-open-failed', message })); + } + resolve(ok(new Ssh2PtySession(id, channel))); } - resolve(ok(new Ssh2PtySession(id, channel))); - } - ); + ); + } catch (e) { + if (settled) return; + settled = true; + clearTimeout(timer); + const message = e instanceof Error ? e.message : String(e); + resolve(err({ kind: 'channel-open-failed', message })); + } }); } From fa80905ebf7482e672241aaf88b2443643549356 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:09:31 +0200 Subject: [PATCH 2/5] fix(ssh): recover sessions after reconnect --- .../conversation-session-supervisor.ts | 11 +++ .../conversation-provider-respawn.test.ts | 51 +++++++++++ .../conversations/impl/ssh-conversation.ts | 73 +++++++++++++++ .../src/main/core/runtime/legacy/ssh-git.ts | 16 +++- .../impl/ssh-terminal-provider.test.ts | 88 ++++++++++++++++--- .../terminals/impl/ssh-terminal-provider.ts | 30 ++++++- .../main/core/workspaces/workspace-factory.ts | 1 + 7 files changed, 251 insertions(+), 19 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/conversations/conversation-session-supervisor.ts b/apps/emdash-desktop/src/main/core/conversations/conversation-session-supervisor.ts index 92ac4e713a..d8fd8ee3da 100644 --- a/apps/emdash-desktop/src/main/core/conversations/conversation-session-supervisor.ts +++ b/apps/emdash-desktop/src/main/core/conversations/conversation-session-supervisor.ts @@ -91,6 +91,17 @@ export class ConversationSessionSupervisor { return pty; } + detachActive(sessionId: string): Pty | undefined { + const runtime = this.runtimes.get(sessionId); + if (!runtime) return undefined; + + runtime.spawnInFlight = undefined; + this.clearRecoveryGraceTimer(runtime); + const pty = runtime.active?.pty; + runtime.active = undefined; + return pty; + } + isDesired(sessionId: string): boolean { return this.runtimes.get(sessionId)?.desired === true; } diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index 96ae778cee..4155f29b3c 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -20,6 +20,9 @@ const buildCommandMock = vi.hoisted(() => ); const installPluginMock = vi.hoisted(() => vi.fn(async () => [])); const writeHooksMock = vi.hoisted(() => vi.fn(async () => [])); +const sshConnectionManagerMock = vi.hoisted(() => ({ + handlers: [] as Array<(event: { type: string; connectionId: string }) => void>, +})); vi.mock('@main/core/dependencies/host-dependency-store', () => ({ hostDependencyStore: { @@ -94,6 +97,17 @@ vi.mock('@main/core/pty/ssh2-pty', () => ({ openSsh2Pty, })); +vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ + sshConnectionManager: { + on: vi.fn( + (_event: string, handler: (event: { type: string; connectionId: string }) => void) => { + sshConnectionManagerMock.handlers.push(handler); + } + ), + off: vi.fn(), + }, +})); + vi.mock('./keystroke-injection', () => ({ scheduleInitialPromptInjection: vi.fn(), })); @@ -192,9 +206,11 @@ function sshProvider( { tmux = false, ctx = {} as never, + connectionId = 'ssh-1', }: { tmux?: boolean; ctx?: ConstructorParameters[0]['ctx']; + connectionId?: string; } = {} ) { return new SshConversationProvider({ @@ -204,6 +220,7 @@ function sshProvider( tmux, ctx, proxy: proxy as never, + connectionId, }); } @@ -255,6 +272,7 @@ describe('conversation provider respawn state', () => { installPluginMock.mockResolvedValue([]); writeHooksMock.mockReset(); writeHooksMock.mockResolvedValue([]); + sshConnectionManagerMock.handlers.length = 0; mockSettings(); vi.mocked(events.emit).mockClear(); vi.mocked(agentHookService.getPort).mockReturnValue(0); @@ -995,4 +1013,37 @@ describe('conversation provider respawn state', () => { expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); expect(events.emit).not.toHaveBeenCalledWith(agentSessionExitedChannel, expect.anything()); }); + + it('detaches stale SSH conversations on disconnect and resumes them on reconnect', async () => { + const firstExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const secondExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const firstPty = fakePty(firstExitHandlers); + const secondPty = fakePty(secondExitHandlers); + openSsh2Pty + .mockResolvedValueOnce({ success: true, data: firstPty }) + .mockResolvedValueOnce({ success: true, data: secondPty }); + const provider = sshProvider(undefined, { connectionId: 'ssh-1' }); + const item = conversation(); + const sessionId = makePtySessionId(item.projectId, item.taskId, item.id); + + await provider.startSession(item); + expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(firstPty); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + + expect((provider as unknown as RespawnState).sessions.has(sessionId)).toBe(false); + expect(ptySessionRegistry.get(sessionId)).toBeUndefined(); + expect(firstPty.kill).toHaveBeenCalledOnce(); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'reconnected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + expect(openSsh2Pty).toHaveBeenCalledTimes(2); + expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); + expect(ptySessionRegistry.get(sessionId)).toBe(secondPty); + }); }); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index 37d985cedc..94c5fd6e46 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -13,7 +13,9 @@ import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { getTerminalColorEnv } from '@main/core/pty/terminal-color-scheme'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; +import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import type { SshConnectionManagerEvent } from '@main/core/ssh/lifecycle/ssh-connection-manager'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { telemetryService } from '@main/lib/telemetry'; @@ -36,6 +38,8 @@ function parseExtraArgs(value: string | undefined): string[] { export class SshConversationProvider implements ConversationProvider { private sessions = new Map(); private knownSessionIds = new Set(); + private conversations = new Map(); + private reconnectSizes = new Map(); private supervisor = new ConversationSessionSupervisor(); private readonly projectId: string; private readonly taskPath: string; @@ -45,6 +49,8 @@ export class SshConversationProvider implements ConversationProvider { private readonly shellSetup?: string; private readonly ctx: IExecutionContext; private readonly proxy: SshClientProxy; + private readonly connectionId: string; + private readonly _handleReconnect: (evt: SshConnectionManagerEvent) => void; constructor({ projectId, @@ -55,6 +61,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup, ctx, proxy, + connectionId, }: { projectId: string; taskPath: string; @@ -64,6 +71,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup?: string; ctx: IExecutionContext; proxy: SshClientProxy; + connectionId: string; }) { this.projectId = projectId; this.taskPath = taskPath; @@ -73,6 +81,24 @@ export class SshConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.ctx = ctx; this.proxy = proxy; + this.connectionId = connectionId; + this._handleReconnect = (evt: SshConnectionManagerEvent) => { + if (evt.connectionId !== this.connectionId) return; + if (evt.type === 'disconnected') { + this.detachStaleSessionsForReconnect(); + return; + } + if (evt.type === 'reconnected') { + this.rehydrate().catch((e: unknown) => { + log.error('SshConversationProvider: rehydrate failed after reconnect', { + taskId: this.taskId, + connectionId: this.connectionId, + error: String(e), + }); + }); + } + }; + sshConnectionManager.on('connection-event', this._handleReconnect); } async startSession( @@ -103,6 +129,7 @@ export class SshConversationProvider implements ConversationProvider { conversation.id ); this.knownSessionIds.add(sessionId); + this.conversations.set(sessionId, conversation); const spawnSize = ptySessionRegistry.getLastSize(sessionId) ?? initialSize; const spawnToken = this.supervisor.beginStart(sessionId, { @@ -271,6 +298,7 @@ export class SshConversationProvider implements ConversationProvider { }, }); this.sessions.set(sessionId, pty); + this.reconnectSizes.delete(sessionId); scheduleInitialPromptInjection({ pty, conversation, @@ -312,6 +340,8 @@ export class SshConversationProvider implements ConversationProvider { this.knownSessionIds.delete(sessionId); this.supervisor.forget(sessionId); } + this.conversations.delete(sessionId); + this.reconnectSizes.delete(sessionId); } async stopSession(conversationId: string): Promise { @@ -334,9 +364,12 @@ export class SshConversationProvider implements ConversationProvider { await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } this.supervisor.forget(sessionId); + this.conversations.delete(sessionId); + this.reconnectSizes.delete(sessionId); } async destroyAll(): Promise { + sshConnectionManager.off('connection-event', this._handleReconnect); const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { @@ -346,6 +379,8 @@ export class SshConversationProvider implements ConversationProvider { this.supervisor.forget(sessionId); } this.knownSessionIds.clear(); + this.conversations.clear(); + this.reconnectSizes.clear(); } async detachAll(): Promise { @@ -359,6 +394,44 @@ export class SshConversationProvider implements ConversationProvider { this.sessions.clear(); } + private detachStaleSessionsForReconnect(): void { + for (const [sessionId, pty] of this.sessions) { + const lastSize = ptySessionRegistry.getLastSize(sessionId); + if (lastSize) this.reconnectSizes.set(sessionId, lastSize); + this.supervisor.detachActive(sessionId); + this.sessions.delete(sessionId); + ptySessionRegistry.unregister(sessionId, { pty }); + try { + pty.kill(); + } catch (e) { + log.warn('SshConversationProvider: error detaching stale PTY after disconnect', { + sessionId, + error: String(e), + }); + } + } + } + + private async rehydrate(): Promise { + await Promise.all( + Array.from(this.conversations.entries()).map(async ([sessionId, conversation]) => { + if (this.sessions.has(sessionId) || !this.supervisor.isDesired(sessionId)) return; + const initialSize = this.reconnectSizes.get(sessionId) ?? { + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + }; + await this.startSessionInternal(conversation, initialSize, true, undefined, true, { + shellRefreshRetried: false, + }).catch((e) => { + log.error('SshConversationProvider: rehydrate failed', { + conversationId: conversation.id, + error: String(e), + }); + }); + }) + ); + } + private scheduleShellRefreshRetry({ conversation, sessionId, diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts index 1ed796a54a..d471461e13 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts @@ -69,6 +69,14 @@ type LegacyWorktreeResource = { repositoryLease: Lease; }; +async function liveModelResult(compute: () => Promise): Promise> { + try { + return ok(await compute()); + } catch (error) { + return err(error); + } +} + /** * Legacy SSH compatibility layer. SSH projects still execute Git through the main * process until the shared Git runtime can run on the remote machine. @@ -243,11 +251,11 @@ class LegacySshGitRepository implements IGitRepository { this.gitCommonDir = gitCommonDir; this.objectStoreDir = `${gitCommonDir}/objects`; this.refsModel = new LiveModel({ - compute: async () => ok(await this.computeRefs()), + compute: () => liveModelResult(() => this.computeRefs()), onError: (error) => log.warn('LegacySshGitRepository: refs refresh failed', { error }), }); this.remotesModel = new LiveModel({ - compute: async () => ok(await this.computeRemotes()), + compute: () => liveModelResult(() => this.computeRemotes()), onError: (error) => log.warn('LegacySshGitRepository: remotes refresh failed', { error }), }); this.timers = [ @@ -418,11 +426,11 @@ class LegacySshGitWorktree implements IGitWorktree { this.worktree = worktreePath; this.repository = repository; this.statusModel = new LiveModel({ - compute: async () => ok(await this.computeStatus()), + compute: () => liveModelResult(() => this.computeStatus()), onError: (error) => log.warn('LegacySshGitWorktree: status refresh failed', { error }), }); this.headModel = new LiveModel({ - compute: async () => ok(await this.computeHead()), + compute: () => liveModelResult(() => this.computeHead()), onError: (error) => log.warn('LegacySshGitWorktree: head refresh failed', { error }), }); this.timers = [ diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index 87fe0b4522..5f0d61bda9 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -9,6 +9,33 @@ import { SshTerminalProvider } from './ssh-terminal-provider'; const ptyMock = vi.hoisted(() => ({ exitHandlers: [] as Array<(info: PtyExitInfo) => void>, + ptys: [] as Array<{ + write: ReturnType; + resize: ReturnType; + kill: ReturnType; + onData: ReturnType; + onExit: ReturnType; + }>, +})); + +const openSsh2PtyMock = vi.hoisted(() => + vi.fn(async () => { + const pty = { + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((handler: (info: PtyExitInfo) => void) => { + ptyMock.exitHandlers.push(handler); + }), + }; + ptyMock.ptys.push(pty); + return { success: true, data: pty }; + }) +); + +const sshConnectionManagerMock = vi.hoisted(() => ({ + handlers: [] as Array<(event: { type: string; connectionId: string }) => void>, })); const previewServerServiceMock = vi.hoisted(() => ({ @@ -21,30 +48,24 @@ const terminalUrlDetectorMock = vi.hoisted(() => ({ })); vi.mock('@main/core/pty/ssh2-pty', () => ({ - openSsh2Pty: vi.fn(async () => ({ - success: true, - data: { - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - onData: vi.fn(), - onExit: vi.fn((handler: (info: PtyExitInfo) => void) => { - ptyMock.exitHandlers.push(handler); - }), - }, - })), + openSsh2Pty: openSsh2PtyMock, })); vi.mock('@main/core/pty/pty-session-registry', () => ({ ptySessionRegistry: { register: vi.fn(), unregister: vi.fn(), + getLastSize: vi.fn(), }, })); vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ sshConnectionManager: { - on: vi.fn(), + on: vi.fn( + (_event: string, handler: (event: { type: string; connectionId: string }) => void) => { + sshConnectionManagerMock.handlers.push(handler); + } + ), off: vi.fn(), }, })); @@ -86,11 +107,15 @@ const proxy = { describe('SshTerminalProvider', () => { beforeEach(() => { ptyMock.exitHandlers.length = 0; + ptyMock.ptys.length = 0; + sshConnectionManagerMock.handlers.length = 0; + openSsh2PtyMock.mockClear(); terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); previewServerServiceMock.registerDetectedTarget.mockClear(); previewServerServiceMock.registerDetectedTarget.mockResolvedValue(undefined); previewServerServiceMock.handleTerminalSourceClosed.mockClear(); vi.mocked(ptySessionRegistry.register).mockClear(); + vi.mocked(ptySessionRegistry.getLastSize).mockReturnValue(undefined); proxy.getRemoteShellProfile = vi.fn(async () => ({ shell: '/bin/bash', env: { PATH: '/usr/bin', HOME: '/home/me' }, @@ -179,4 +204,41 @@ describe('SshTerminalProvider', () => { urlPath: '/', }); }); + + it('detaches stale sessions on disconnect so reconnect can rehydrate them', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + + await provider.spawnTerminal(terminal, { cols: 120, rows: 40 }); + const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); + expect( + (provider as unknown as { sessions: Map }).sessions.has(sessionId) + ).toBe(true); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + + expect( + (provider as unknown as { sessions: Map }).sessions.has(sessionId) + ).toBe(false); + expect(ptySessionRegistry.unregister).toHaveBeenCalledWith(sessionId, { pty: ptyMock.ptys[0] }); + expect(ptyMock.ptys[0]!.kill).toHaveBeenCalledOnce(); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'reconnected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + expect(openSsh2PtyMock).toHaveBeenCalledTimes(2); + expect( + (provider as unknown as { sessions: Map }).sessions.has(sessionId) + ).toBe(true); + }); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index 2f29d3c4d5..a219fd6b9a 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -43,6 +43,7 @@ export class SshTerminalProvider implements TerminalProvider { private shellProfiles = new Map(); private respawnCounts = new Map(); private terminals = new Map(); + private reconnectSizes = new Map(); private readonly projectId: string; private readonly workspaceId: string; private readonly scopeId: string; @@ -89,7 +90,12 @@ export class SshTerminalProvider implements TerminalProvider { this.proxy = proxy; this.connectionId = connectionId; this._handleReconnect = (evt: SshConnectionManagerEvent) => { - if (evt.type === 'reconnected' && evt.connectionId === this.connectionId) { + if (evt.connectionId !== this.connectionId) return; + if (evt.type === 'disconnected') { + this.detachStaleSessionsForReconnect(); + return; + } + if (evt.type === 'reconnected') { this.rehydrate().catch((e: unknown) => { log.error('SshTerminalProvider: rehydrate failed after reconnect', { scopeId: this.scopeId, @@ -288,6 +294,7 @@ export class SshTerminalProvider implements TerminalProvider { metadata, }); this.sessions.set(sessionId, pty); + this.reconnectSizes.delete(sessionId); } private async getSessionShellProfile( @@ -322,7 +329,7 @@ export class SshTerminalProvider implements TerminalProvider { terminals.map(async (terminal) => { const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); if (this.sessions.has(sessionId)) return; - await this.spawnTerminal(terminal).catch((e) => { + await this.spawnTerminal(terminal, this.reconnectSizes.get(sessionId)).catch((e) => { log.error('SshTerminalProvider: rehydrate failed', { terminalId: terminal.id, error: String(e), @@ -372,4 +379,23 @@ export class SshTerminalProvider implements TerminalProvider { } this.sessions.clear(); } + + private detachStaleSessionsForReconnect(): void { + for (const [sessionId, pty] of this.sessions) { + const lastSize = ptySessionRegistry.getLastSize(sessionId); + if (lastSize) this.reconnectSizes.set(sessionId, lastSize); + this.sessions.delete(sessionId); + this.shellProfiles.delete(sessionId); + this.respawnCounts.delete(sessionId); + ptySessionRegistry.unregister(sessionId, { pty }); + try { + pty.kill(); + } catch (e) { + log.warn('SshTerminalProvider: error detaching stale PTY after disconnect', { + sessionId, + error: String(e), + }); + } + } + } } diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index 62229c07ba..df9ca226d7 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -316,6 +316,7 @@ export async function buildTaskProviders( shellSetup: opts.shellSetup, ctx, proxy: type.proxy, + connectionId: type.connectionId, taskEnvVars: opts.taskEnvVars, }), terminals: new SshTerminalProvider({ From ee276a75ad005a5ec7898bc9c62dd1be00b989d7 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:44:35 +0200 Subject: [PATCH 3/5] fix(ssh): stabilize reconnect PTY cleanup --- .../src/main/core/pty/ssh2-pty.test.ts | 33 ++++++++++++++++++ .../src/main/core/pty/ssh2-pty.ts | 6 ++-- .../impl/ssh-terminal-provider.test.ts | 34 +++++++++++++++++++ .../terminals/impl/ssh-terminal-provider.ts | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts index 31d2111e94..977a4f2729 100644 --- a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts +++ b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.test.ts @@ -73,6 +73,39 @@ describe('Ssh2PtySession', () => { } }); + it('destroys the client used to open the timed-out channel, not a later reconnect', async () => { + vi.useFakeTimers(); + try { + const originalClient = { destroy: vi.fn() }; + const reconnectedClient = { destroy: vi.fn() }; + let currentClient = originalClient; + const proxy = { + get client() { + return currentClient; + }, + execPty: vi.fn(() => { + currentClient = reconnectedClient; + }), + }; + + const resultPromise = openSsh2Pty(proxy as never, { + id: 'ssh-session', + command: 'bash', + cols: 80, + rows: 24, + }); + + await vi.advanceTimersByTimeAsync(15_000); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(originalClient.destroy).toHaveBeenCalledOnce(); + expect(reconnectedClient.destroy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('returns a failed open when execPty throws synchronously', async () => { const proxy = { execPty: vi.fn(() => { diff --git a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts index 676f4ca397..8a2232c3cf 100644 --- a/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts +++ b/apps/emdash-desktop/src/main/core/pty/ssh2-pty.ts @@ -1,5 +1,5 @@ import { err, ok, type Result } from '@emdash/shared'; -import type { ClientChannel } from 'ssh2'; +import type { Client, ClientChannel } from 'ssh2'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { log } from '@main/lib/logger'; import { normalizeSignal } from './exit-signals'; @@ -70,11 +70,12 @@ export async function openSsh2Pty( const { id, command, cols, rows } = options; return new Promise((resolve) => { let settled = false; + let clientSnapshot: Client | null = null; const timer = setTimeout(() => { if (settled) return; settled = true; try { - proxy.client.destroy(); + clientSnapshot?.destroy(); } catch {} resolve( err({ @@ -85,6 +86,7 @@ export async function openSsh2Pty( }, CHANNEL_OPEN_TIMEOUT_MS); try { + clientSnapshot = proxy.client; proxy.execPty( command, { diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index 5f0d61bda9..3110562490 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -111,6 +111,7 @@ describe('SshTerminalProvider', () => { sshConnectionManagerMock.handlers.length = 0; openSsh2PtyMock.mockClear(); terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); + vi.mocked(ptySessionRegistry.unregister).mockClear(); previewServerServiceMock.registerDetectedTarget.mockClear(); previewServerServiceMock.registerDetectedTarget.mockResolvedValue(undefined); previewServerServiceMock.handleTerminalSourceClosed.mockClear(); @@ -241,4 +242,37 @@ describe('SshTerminalProvider', () => { (provider as unknown as { sessions: Map }).sessions.has(sessionId) ).toBe(true); }); + + it('clears reconnect sizes when killing a terminal detached for reconnect', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + + await provider.spawnTerminal(terminal, { cols: 120, rows: 40 }); + const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); + vi.mocked(ptySessionRegistry.getLastSize).mockReturnValue({ cols: 120, rows: 40 }); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + + expect( + (provider as unknown as { reconnectSizes: Map }).reconnectSizes.has( + sessionId + ) + ).toBe(true); + + await provider.killTerminal(terminal.id); + + expect( + (provider as unknown as { reconnectSizes: Map }).reconnectSizes.has( + sessionId + ) + ).toBe(false); + }); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index a219fd6b9a..d5202786b6 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -352,6 +352,7 @@ export class SshTerminalProvider implements TerminalProvider { } this.terminals.delete(terminalId); this.shellProfiles.delete(sessionId); + this.reconnectSizes.delete(sessionId); if (this.tmux) { await killTmuxSession(this.ctx, makeTmuxSessionName(sessionId)); } @@ -367,6 +368,7 @@ export class SshTerminalProvider implements TerminalProvider { this.knownSessionIds.clear(); this.terminals.clear(); this.shellProfiles.clear(); + this.reconnectSizes.clear(); } async detachAll(): Promise { From d06dd374a83e04a381c745cc44fb4c0bfd5ef73a Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:00:04 +0200 Subject: [PATCH 4/5] fix(ssh): rehydrate sessions after reconnect --- .../conversation-provider-respawn.test.ts | 55 ++++++++++++++++++- .../conversations/impl/ssh-conversation.ts | 9 +-- .../impl/ssh-terminal-provider.test.ts | 42 ++++++++++++++ .../terminals/impl/ssh-terminal-provider.ts | 4 +- 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index 4155f29b3c..1437260658 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -167,6 +167,8 @@ vi.mock('@main/core/settings/settings-service', () => ({ const { events } = await import('@main/lib/events'); const { agentHookService } = await import('@main/core/agent-hooks/agent-hook-service'); const { appSettingsService } = await import('@main/core/settings/settings-service'); +const { sshConnectionManager } = + await import('@main/core/ssh/lifecycle/production-ssh-connection-manager'); type RespawnState = { knownSessionIds: Set; @@ -273,6 +275,7 @@ describe('conversation provider respawn state', () => { writeHooksMock.mockReset(); writeHooksMock.mockResolvedValue([]); sshConnectionManagerMock.handlers.length = 0; + vi.mocked(sshConnectionManager.off).mockClear(); mockSettings(); vi.mocked(events.emit).mockClear(); vi.mocked(agentHookService.getPort).mockReturnValue(0); @@ -1014,7 +1017,7 @@ describe('conversation provider respawn state', () => { expect(events.emit).not.toHaveBeenCalledWith(agentSessionExitedChannel, expect.anything()); }); - it('detaches stale SSH conversations on disconnect and resumes them on reconnect', async () => { + it('detaches stale SSH conversations on disconnect and resumes them when connected', async () => { const firstExitHandlers: Array<(info: PtyExitInfo) => void> = []; const secondExitHandlers: Array<(info: PtyExitInfo) => void> = []; const firstPty = fakePty(firstExitHandlers); @@ -1038,7 +1041,7 @@ describe('conversation provider respawn state', () => { expect(firstPty.kill).toHaveBeenCalledOnce(); for (const handler of sshConnectionManagerMock.handlers) { - handler({ type: 'reconnected', connectionId: 'ssh-1' }); + handler({ type: 'connected', connectionId: 'ssh-1' }); } await new Promise((resolve) => setImmediate(resolve)); @@ -1046,4 +1049,52 @@ describe('conversation provider respawn state', () => { expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); expect(ptySessionRegistry.get(sessionId)).toBe(secondPty); }); + + it('clears in-flight SSH conversation starts on disconnect so reconnect can resume', async () => { + const firstExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const secondExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const firstPty = fakePty(firstExitHandlers); + const secondPty = fakePty(secondExitHandlers); + let resolveFirstOpen: ((value: { success: true; data: Pty }) => void) | undefined; + openSsh2Pty + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstOpen = resolve; + }) + ) + .mockResolvedValueOnce({ success: true, data: secondPty }); + const provider = sshProvider(undefined, { connectionId: 'ssh-1' }); + const item = conversation(); + const sessionId = makePtySessionId(item.projectId, item.taskId, item.id); + + const firstStart = provider.startSession(item); + await new Promise((resolve) => setImmediate(resolve)); + expect(openSsh2Pty).toHaveBeenCalledTimes(1); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'reconnected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + expect(openSsh2Pty).toHaveBeenCalledTimes(2); + expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); + + resolveFirstOpen?.({ success: true, data: firstPty }); + await firstStart; + + expect(firstPty.kill).toHaveBeenCalledOnce(); + expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); + }); + + it('unsubscribes SSH connection listeners when detached', async () => { + const provider = sshProvider(undefined, { connectionId: 'ssh-1' }); + + await provider.detachAll(); + + expect(sshConnectionManager.off).toHaveBeenCalledWith('connection-event', expect.any(Function)); + }); }); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index 94c5fd6e46..5992c1ba6a 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -88,7 +88,7 @@ export class SshConversationProvider implements ConversationProvider { this.detachStaleSessionsForReconnect(); return; } - if (evt.type === 'reconnected') { + if (evt.type === 'connected' || evt.type === 'reconnected') { this.rehydrate().catch((e: unknown) => { log.error('SshConversationProvider: rehydrate failed after reconnect', { taskId: this.taskId, @@ -369,7 +369,6 @@ export class SshConversationProvider implements ConversationProvider { } async destroyAll(): Promise { - sshConnectionManager.off('connection-event', this._handleReconnect); const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { @@ -384,6 +383,7 @@ export class SshConversationProvider implements ConversationProvider { } async detachAll(): Promise { + sshConnectionManager.off('connection-event', this._handleReconnect); for (const [sessionId, pty] of this.sessions) { this.supervisor.stop(sessionId); try { @@ -395,11 +395,12 @@ export class SshConversationProvider implements ConversationProvider { } private detachStaleSessionsForReconnect(): void { - for (const [sessionId, pty] of this.sessions) { + for (const [sessionId] of this.conversations) { const lastSize = ptySessionRegistry.getLastSize(sessionId); if (lastSize) this.reconnectSizes.set(sessionId, lastSize); - this.supervisor.detachActive(sessionId); + const pty = this.supervisor.detachActive(sessionId) ?? this.sessions.get(sessionId); this.sessions.delete(sessionId); + if (!pty) continue; ptySessionRegistry.unregister(sessionId, { pty }); try { pty.kill(); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index 3110562490..278362e6de 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -82,6 +82,9 @@ vi.mock('@main/core/pty/terminal-color-scheme', () => ({ getTerminalColorEnv: vi.fn().mockResolvedValue({}), })); +const { sshConnectionManager } = + await import('@main/core/ssh/lifecycle/production-ssh-connection-manager'); + const terminal: Terminal = { id: 'terminal-1', projectId: 'project-1', @@ -110,6 +113,7 @@ describe('SshTerminalProvider', () => { ptyMock.ptys.length = 0; sshConnectionManagerMock.handlers.length = 0; openSsh2PtyMock.mockClear(); + vi.mocked(sshConnectionManager.off).mockClear(); terminalUrlDetectorMock.wireTerminalUrlDetector.mockClear(); vi.mocked(ptySessionRegistry.unregister).mockClear(); previewServerServiceMock.registerDetectedTarget.mockClear(); @@ -243,6 +247,29 @@ describe('SshTerminalProvider', () => { ).toBe(true); }); + it('rehydrates detached sessions when manually connected', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + + await provider.spawnTerminal(terminal); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'connected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + expect(openSsh2PtyMock).toHaveBeenCalledTimes(2); + }); + it('clears reconnect sizes when killing a terminal detached for reconnect', async () => { const provider = new SshTerminalProvider({ projectId: terminal.projectId, @@ -275,4 +302,19 @@ describe('SshTerminalProvider', () => { ) ).toBe(false); }); + + it('unsubscribes SSH connection listeners when detached', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + + await provider.detachAll(); + + expect(sshConnectionManager.off).toHaveBeenCalledWith('connection-event', expect.any(Function)); + }); }); diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index d5202786b6..bef0ea0323 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -95,7 +95,7 @@ export class SshTerminalProvider implements TerminalProvider { this.detachStaleSessionsForReconnect(); return; } - if (evt.type === 'reconnected') { + if (evt.type === 'connected' || evt.type === 'reconnected') { this.rehydrate().catch((e: unknown) => { log.error('SshTerminalProvider: rehydrate failed after reconnect', { scopeId: this.scopeId, @@ -359,7 +359,6 @@ export class SshTerminalProvider implements TerminalProvider { } async destroyAll(): Promise { - sshConnectionManager.off('connection-event', this._handleReconnect); const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { @@ -372,6 +371,7 @@ export class SshTerminalProvider implements TerminalProvider { } async detachAll(): Promise { + sshConnectionManager.off('connection-event', this._handleReconnect); for (const [sessionId, pty] of this.sessions) { try { pty.kill(); From 4aa9fda886c482a076cd0b6e0873d716c2668dcc Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:19:07 +0200 Subject: [PATCH 5/5] fix(ssh): cancel rehydrate after detach --- .../conversation-provider-respawn.test.ts | 35 +++++++++++++ .../conversations/impl/ssh-conversation.ts | 9 +++- .../impl/ssh-terminal-provider.test.ts | 51 ++++++++++++++++++- .../terminals/impl/ssh-terminal-provider.ts | 17 ++++++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index 1437260658..a050fca5c4 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -1090,6 +1090,41 @@ describe('conversation provider respawn state', () => { expect((provider as unknown as RespawnState).sessions.get(sessionId)).toBe(secondPty); }); + it('cancels in-flight SSH rehydrate starts after detachAll', async () => { + const firstExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const rehydratedExitHandlers: Array<(info: PtyExitInfo) => void> = []; + const firstPty = fakePty(firstExitHandlers); + const rehydratedPty = fakePty(rehydratedExitHandlers); + let resolveRehydrateOpen: ((value: { success: true; data: Pty }) => void) | undefined; + openSsh2Pty.mockResolvedValueOnce({ success: true, data: firstPty }).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRehydrateOpen = resolve; + }) + ); + const provider = sshProvider(undefined, { connectionId: 'ssh-1' }); + const item = conversation(); + const sessionId = makePtySessionId(item.projectId, item.taskId, item.id); + + await provider.startSession(item); + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'reconnected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + await provider.detachAll(); + resolveRehydrateOpen?.({ success: true, data: rehydratedPty }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(openSsh2Pty).toHaveBeenCalledTimes(2); + expect(rehydratedPty.kill).toHaveBeenCalledOnce(); + expect((provider as unknown as RespawnState).sessions.has(sessionId)).toBe(false); + expect(ptySessionRegistry.get(sessionId)).toBeUndefined(); + }); + it('unsubscribes SSH connection listeners when detached', async () => { const provider = sshProvider(undefined, { connectionId: 'ssh-1' }); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index 5992c1ba6a..b16b7f4d9e 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -41,6 +41,7 @@ export class SshConversationProvider implements ConversationProvider { private conversations = new Map(); private reconnectSizes = new Map(); private supervisor = new ConversationSessionSupervisor(); + private detached = false; private readonly projectId: string; private readonly taskPath: string; private readonly taskId: string; @@ -123,6 +124,7 @@ export class SshConversationProvider implements ConversationProvider { requireDesired: boolean, options: { shellRefreshRetried: boolean } ): Promise { + if (this.detached) return; const sessionId = makePtySessionId( conversation.projectId, conversation.taskId, @@ -383,9 +385,12 @@ export class SshConversationProvider implements ConversationProvider { } async detachAll(): Promise { + this.detached = true; sshConnectionManager.off('connection-event', this._handleReconnect); - for (const [sessionId, pty] of this.sessions) { + for (const sessionId of this.knownSessionIds) { this.supervisor.stop(sessionId); + } + for (const [sessionId, pty] of this.sessions) { try { pty.kill(); } catch {} @@ -414,8 +419,10 @@ export class SshConversationProvider implements ConversationProvider { } private async rehydrate(): Promise { + if (this.detached) return; await Promise.all( Array.from(this.conversations.entries()).map(async ([sessionId, conversation]) => { + if (this.detached) return; if (this.sessions.has(sessionId) || !this.supervisor.isDesired(sessionId)) return; const initialSize = this.reconnectSizes.get(sessionId) ?? { cols: DEFAULT_COLS, diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index 278362e6de..995eab4b5d 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { PtyExitInfo } from '@main/core/pty/pty'; +import type { Pty, PtyExitInfo } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { makePtySessionId } from '@shared/core/pty/ptySessionId'; @@ -303,6 +303,55 @@ describe('SshTerminalProvider', () => { ).toBe(false); }); + it('cancels in-flight rehydrate starts after detachAll', async () => { + const provider = new SshTerminalProvider({ + projectId: terminal.projectId, + scopeId: terminal.taskId, + taskPath: '/repo', + ctx, + proxy, + connectionId: 'ssh-1', + }); + let resolveRehydrateOpen: ((value: { success: true; data: Pty }) => void) | undefined; + + await provider.spawnTerminal(terminal); + const firstPty = ptyMock.ptys[0]; + openSsh2PtyMock.mockImplementationOnce( + (() => + new Promise<{ success: true; data: Pty }>((resolve) => { + resolveRehydrateOpen = resolve; + })) as never + ); + + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'disconnected', connectionId: 'ssh-1' }); + } + for (const handler of sshConnectionManagerMock.handlers) { + handler({ type: 'reconnected', connectionId: 'ssh-1' }); + } + await new Promise((resolve) => setImmediate(resolve)); + + await provider.detachAll(); + const rehydratedPty = { + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + } satisfies Pty; + resolveRehydrateOpen?.({ success: true, data: rehydratedPty }); + await new Promise((resolve) => setImmediate(resolve)); + + const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); + expect(openSsh2PtyMock).toHaveBeenCalledTimes(2); + expect(firstPty?.kill).toHaveBeenCalledOnce(); + expect(rehydratedPty.kill).toHaveBeenCalledOnce(); + expect( + (provider as unknown as { sessions: Map }).sessions.has(sessionId) + ).toBe(false); + expect(ptySessionRegistry.register).toHaveBeenCalledTimes(1); + }); + it('unsubscribes SSH connection listeners when detached', async () => { const provider = new SshTerminalProvider({ projectId: terminal.projectId, diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts index bef0ea0323..7fbd392c2f 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -44,6 +44,7 @@ export class SshTerminalProvider implements TerminalProvider { private respawnCounts = new Map(); private terminals = new Map(); private reconnectSizes = new Map(); + private detached = false; private readonly projectId: string; private readonly workspaceId: string; private readonly scopeId: string; @@ -163,6 +164,7 @@ export class SshTerminalProvider implements TerminalProvider { metadata: PtySessionMetadata | undefined, policy: SpawnPolicy ): Promise { + if (this.detached) return; const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); this.knownSessionIds.add(sessionId); if (this.sessions.has(sessionId)) return; @@ -206,6 +208,13 @@ export class SshTerminalProvider implements TerminalProvider { } const pty = result.data; + if (this.detached) { + try { + pty.kill(); + } catch {} + return; + } + if (policy.watchDevServer) { wireTerminalUrlDetector({ pty, @@ -324,9 +333,11 @@ export class SshTerminalProvider implements TerminalProvider { * already running. */ async rehydrate(): Promise { + if (this.detached) return; const terminals = Array.from(this.terminals.values()); await Promise.all( terminals.map(async (terminal) => { + if (this.detached) return; const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); if (this.sessions.has(sessionId)) return; await this.spawnTerminal(terminal, this.reconnectSizes.get(sessionId)).catch((e) => { @@ -371,13 +382,17 @@ export class SshTerminalProvider implements TerminalProvider { } async detachAll(): Promise { + this.detached = true; sshConnectionManager.off('connection-event', this._handleReconnect); + for (const sessionId of this.knownSessionIds) { + this.shellProfiles.delete(sessionId); + this.respawnCounts.delete(sessionId); + } for (const [sessionId, pty] of this.sessions) { try { pty.kill(); } catch {} ptySessionRegistry.unregister(sessionId); - this.shellProfiles.delete(sessionId); } this.sessions.clear(); }