fix(ssh): expose unverifiable PTY liveness

Track a PTY whose verification lost transport without classifying it live or exited. Keep provider routing ownership, but return null through the existing tri-state liveness probe until a later inventory or exit supplies evidence.
This commit is contained in:
Brennan Benson
2026-08-16 22:43:21 -07:00
parent 8e6e1c6136
commit bb70d409ea
4 changed files with 32 additions and 5 deletions
+3
View File
@@ -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.
+23 -4
View File
@@ -34,6 +34,7 @@ export class SshPtyProvider implements IPtyProvider {
private mux: SshChannelMultiplexer
private connectionId: string
private livePtyIds = new Set<string>()
private unverifiablePtyIds = new Set<string>()
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
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<void> {
@@ -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<boolean | null> =>
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<string> {
const result = await this.mux.request('pty.getDefaultShell')
@@ -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)
})
@@ -261,6 +261,7 @@ export async function reattachSshPtySessionWithExitFence(
export async function reattachSshPtySessionForSpawn(
args: Parameters<typeof reattachSshPtySessionWithExitFence>[0] & {
acceptLivePty: (relayPtyId: string) => void
acceptUnverifiablePty: (relayPtyId: string) => void
}
): Promise<PtySpawnResult> {
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 }