diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index fb240b1d4d6..df59706a1ae 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -7754,6 +7754,9 @@ export function registerPtyHandlers( return null } try { + if (provider.probePtyLiveness) { + return await provider.probePtyLiveness(args.id) + } return provider.hasPty(args.id) } catch { // Why: liveness is only allowed to close panes on an authoritative false. diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index b683ec2fa7d..ac0a70117b6 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -34,6 +34,7 @@ export class SshPtyProvider implements IPtyProvider { private mux: SshChannelMultiplexer private connectionId: string private livePtyIds = new Set() + private unverifiablePtyIds = new Set() readonly getAppliedSize: NonNullable private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() @@ -58,6 +59,7 @@ export class SshPtyProvider implements IPtyProvider { toAppPtyId: (id) => this.toAppPtyId(id), livePtyIds: this.livePtyIds, recordExit: (relayPtyId, incarnationId) => { + this.unverifiablePtyIds.delete(this.toAppPtyId(relayPtyId)) this.spawnExitRaces.recordExit(relayPtyId, incarnationId) } }) @@ -66,6 +68,7 @@ export class SshPtyProvider implements IPtyProvider { dispose(): void { this.outputState.dispose() this.livePtyIds.clear() + this.unverifiablePtyIds.clear() } getConnectionId = (): string => this.connectionId @@ -100,7 +103,8 @@ export class SshPtyProvider implements IPtyProvider { this.outputState.installReceivingActivation(relayPtyId, activation), rememberPtyIncarnation: (relayPtyId, incarnationId) => this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId), - acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId) + acceptLivePty: (relayPtyId) => this.acceptLivePty(relayPtyId), + acceptUnverifiablePty: (relayPtyId) => this.acceptUnverifiablePty(relayPtyId) }) } @@ -127,7 +131,7 @@ export class SshPtyProvider implements IPtyProvider { this.outputState.installReceivingActivation(id, activation), rememberPtyIncarnation: (id, incarnation) => this.outputState.rememberPtyIncarnation(id, incarnation), - acceptLivePty: (id) => this.livePtyIds.add(id), + acceptLivePty: (id) => this.acceptLivePty(id), toAppPtyId: this.toAppPtyId }) } @@ -215,6 +219,7 @@ export class SshPtyProvider implements IPtyProvider { relayTimeoutOptions(opts.deadlineMs) ) this.livePtyIds.delete(id) + this.unverifiablePtyIds.delete(id) } async sendSignal(id: string, signal: string): Promise { @@ -281,14 +286,28 @@ export class SshPtyProvider implements IPtyProvider { ) const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) for (const process of processes) { - this.livePtyIds.add(process.id) + this.acceptLivePty(process.id) const relayPtyId = this.toRelayPtyId(process.id) this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) } return processes } - hasPty = (id: string): boolean => this.livePtyIds.has(id) + // `hasPty` retains routing ownership; liveness callers must use the tri-state probe. + hasPty = (id: string): boolean => this.livePtyIds.has(id) || this.unverifiablePtyIds.has(id) + + probePtyLiveness = async (id: string): Promise => + this.unverifiablePtyIds.has(id) ? null : this.livePtyIds.has(id) + + private acceptLivePty(id: string): void { + this.unverifiablePtyIds.delete(id) + this.livePtyIds.add(id) + } + + private acceptUnverifiablePty(id: string): void { + this.livePtyIds.delete(id) + this.unverifiablePtyIds.add(id) + } async getDefaultShell(): Promise { const result = await this.mux.request('pty.getDefaultShell') diff --git a/src/main/providers/ssh-pty-restore-required-reattach.test.ts b/src/main/providers/ssh-pty-restore-required-reattach.test.ts index b8a36cf1c65..d824be7957f 100644 --- a/src/main/providers/ssh-pty-restore-required-reattach.test.ts +++ b/src/main/providers/ssh-pty-restore-required-reattach.test.ts @@ -71,6 +71,7 @@ describe('SSH PTY reattach when the relay requires source restoration', () => { expect(message).not.toContain(SSH_SESSION_EXPIRED_ERROR) expect(message).toContain(SSH_PTY_RESTORE_REQUIRED_ERROR) expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(true) + await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBe(true) }) it('reports unverifiable when the fresh delivery attach loses contact', async () => { @@ -84,7 +85,8 @@ describe('SSH PTY reattach when the relay requires source restoration', () => { expect(message).toContain(SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR) expect(message).not.toContain(SSH_SESSION_EXPIRED_ERROR) - expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false) + expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(true) + await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBeNull() expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(2) }) @@ -99,6 +101,7 @@ describe('SSH PTY reattach when the relay requires source restoration', () => { expect(message).toContain(`${SSH_SESSION_EXPIRED_ERROR}: pty-old`) expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false) + await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBe(false) expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(2) }) diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 9f93a2a8a08..dda07f09ef3 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -261,6 +261,7 @@ export async function reattachSshPtySessionWithExitFence( export async function reattachSshPtySessionForSpawn( args: Parameters[0] & { acceptLivePty: (relayPtyId: string) => void + acceptUnverifiablePty: (relayPtyId: string) => void } ): Promise { let result: SshPtyReattachResult | undefined @@ -284,6 +285,7 @@ export async function reattachSshPtySessionForSpawn( throw error } // Why: losing contact during verification is unverifiable, not live or exited. + args.acceptUnverifiablePty(unresumable.id) throw new Error( `${SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR}: ${toRelaySshPtyId(args.connectionId, unresumable.id)}`, { cause: error }