diff --git a/src/main/providers/ssh-pty-errors.ts b/src/main/providers/ssh-pty-errors.ts index fa2f3edffad..279ad495561 100644 --- a/src/main/providers/ssh-pty-errors.ts +++ b/src/main/providers/ssh-pty-errors.ts @@ -24,8 +24,7 @@ export function isSshPtyExitedEvidenceError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) return ( !isSshPtyIdentityMismatchError(error) && - (message.includes(SSH_SESSION_EXPIRED_ERROR) || - message === 'agent_session_exited_during_start') + (message.includes(SSH_SESSION_EXPIRED_ERROR) || message === 'agent_session_exited_during_start') ) } diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 3eebb3574b3..61dd03d5a0f 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -84,12 +84,25 @@ vi.mock('../providers/ssh-pty-provider', () => ({ isSshPtyNotFoundError: (error: unknown) => String(error).includes('not found'), isSshPtyIdentityMismatchError: (error: unknown) => String(error).includes('identity mismatch'), SshPtyProvider: class MockSshPtyProvider { + readonly initialUnverifiablePtyIds: Set onData = vi.fn().mockReturnValue(() => {}) onReplay = vi.fn().mockReturnValue(() => {}) onExit = vi.fn().mockReturnValue(() => {}) attach = vi.fn().mockResolvedValue(undefined) attachForReconnect = vi.fn().mockResolvedValue({}) + acceptUnverifiablePty = vi.fn() + acceptExitedPty = vi.fn() dispose = vi.fn() + + constructor( + _connectionId: string, + _mux: unknown, + _remoteCliBridgeEnv: unknown, + _providerGeneration: number, + initialUnverifiablePtyIds: Iterable = [] + ) { + this.initialUnverifiablePtyIds = new Set(initialUnverifiablePtyIds) + } } })) vi.mock('../providers/ssh-filesystem-provider', () => ({ @@ -181,6 +194,42 @@ describe('SshRelaySession reconnect incarnation ordering', () => { vi.mocked(getPtyIdsForConnection).mockReturnValue([]) }) + it('keeps a transiently unreattached PTY unverifiable after the relay becomes ready', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-unverifiable']) + vi.mocked(getSshPtyProvider).mockImplementationOnce(() => { + const provider = vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] as unknown as { + attachForReconnect: ReturnType + } + provider.attachForReconnect.mockRejectedValue(new Error('transport interrupted')) + return provider as never + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + try { + await session.establish(mockConn) + const provider = vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] as unknown as { + initialUnverifiablePtyIds: Set + acceptUnverifiablePty: ReturnType + acceptExitedPty: ReturnType + } + const appPtyId = 'ssh:target-1@@pty-unverifiable' + + expect(session.getState()).toBe('ready') + expect(provider.initialUnverifiablePtyIds).toContain(appPtyId) + expect(provider.acceptUnverifiablePty).toHaveBeenCalledWith(appPtyId) + expect(provider.acceptExitedPty).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'target-1', + 'pty-unverifiable', + 'expired' + ) + } finally { + warn.mockRestore() + } + }) + it('bounds fifty reattaches to eight workers without slow or failed sibling head-of-line delay', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index c7b716c70af..2f36c537f08 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -66,9 +66,6 @@ vi.mock('../providers/ssh-pty-provider', () => ({ isSshPtyIdentityMismatchError: (err: unknown) => (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), SshPtyProvider: class MockSshPtyProvider { - private livePtyIds = new Set() - private unverifiablePtyIds = new Set() - private exitedPtyIds = new Set() onData = vi.fn().mockReturnValue(() => {}) onReplay = vi.fn().mockReturnValue(() => {}) onExit = vi.fn().mockReturnValue(() => {}) @@ -76,32 +73,6 @@ vi.mock('../providers/ssh-pty-provider', () => ({ attachForReconnect = vi.fn().mockResolvedValue({}) setPtyDeliveryPauseAdapter = pauseAdapterMock dispose = vi.fn() - - constructor( - _connectionId: string, - _mux: unknown, - _remoteCliBridgeEnv: unknown, - _providerGeneration: number, - initialUnverifiablePtyIds: Iterable = [] - ) { - this.unverifiablePtyIds = new Set(initialUnverifiablePtyIds) - } - - hasPty = (id: string): boolean => this.livePtyIds.has(id) || this.unverifiablePtyIds.has(id) - probePtyLiveness = vi.fn( - async (id: string): Promise => - this.livePtyIds.has(id) ? true : this.exitedPtyIds.has(id) ? false : null - ) - acceptUnverifiablePty = vi.fn((id: string) => { - this.livePtyIds.delete(id) - this.exitedPtyIds.delete(id) - this.unverifiablePtyIds.add(id) - }) - acceptExitedPty = vi.fn((id: string) => { - this.livePtyIds.delete(id) - this.unverifiablePtyIds.delete(id) - this.exitedPtyIds.add(id) - }) } })) @@ -235,41 +206,6 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('keeps a transiently unreattached PTY unverifiable after the relay becomes ready', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-unverifiable']) - vi.mocked(getSshPtyProvider).mockImplementationOnce(() => { - const provider = vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] as { - attachForReconnect: ReturnType - } - provider.attachForReconnect.mockRejectedValue(new Error('transport interrupted')) - return provider as never - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - try { - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] as { - hasPty: (id: string) => boolean - probePtyLiveness: (id: string) => Promise - } - const appPtyId = 'ssh:target-1@@pty-unverifiable' - - expect(session.getState()).toBe('ready') - expect(provider.hasPty(appPtyId)).toBe(true) - await expect(provider.probePtyLiveness(appPtyId)).resolves.toBeNull() - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-unverifiable', - 'expired' - ) - } finally { - warn.mockRestore() - } - }) - it('continues provider registration when the relay managed-hook request fails', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' muxRequestMock.mockImplementation(async (method: string) => {