diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 545f66ca892..ec85118e5b7 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -14,6 +14,7 @@ import type { import { adoptOwningProvider, attachDaemonOwnedSession, + ownerForDaemonOwnedOperation, findDaemonAdapter, listProviderSessionIds } from './degraded-daemon-session-routing' @@ -89,6 +90,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { attach = (id: string): ReturnType => attachDaemonOwnedSession(this.providerFor(id), this.fallback, id) + /** Routing for anything that changes or feeds a session; see ownerForDaemonOwnedOperation. */ + private ownerFor(id: string): IPtyProvider { + return ownerForDaemonOwnedOperation(this.providerFor(id), this.fallback, id) + } + hasPty(id: string): boolean { const mapped = this.sessionProviders.get(id) return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null @@ -109,11 +115,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { )?.providesAgentSessionOwnerListings?.(ptyId) === true write(id: string, data: string): void { - this.providerFor(id).write(id, data) + this.ownerFor(id).write(id, data) } resize(id: string, cols: number, rows: number): void { - this.providerFor(id).resize(id, cols, rows) + this.ownerFor(id).resize(id, cols, rows) } pauseProducer(id: string): void { @@ -132,14 +138,14 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { id: string, opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } ): Promise { - await this.providerFor(id).shutdown(id, opts) + await this.ownerFor(id).shutdown(id, opts) if (!opts.keepHistory) { this.sessionProviders.delete(id) } } async sendSignal(id: string, signal: string): Promise { - await this.providerFor(id).sendSignal(id, signal) + await this.ownerFor(id).sendSignal(id, signal) } async getCwd(id: string): Promise { diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts index 7fb5a9a00c8..48aa3870baf 100644 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ b/src/main/daemon/degraded-daemon-session-routing.ts @@ -1,6 +1,6 @@ import type { IPtyProvider } from '../providers/types' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { SessionNotFoundError } from './daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' export function listProviderSessionIds( sessionProviders: ReadonlyMap, @@ -26,6 +26,29 @@ export async function attachDaemonOwnedSession( return await owner.attach(sessionId) } +/** + * Session operations that must never be answered by the in-process fallback on another + * provider's behalf. An unknown id resolves to the fallback, whose shutdown returns + * silently and whose write/resize are no-ops — so a daemon-owned session reads as closed + * while its agent keeps running, and typing into it disappears. Route there only when the + * fallback genuinely owns the pty; otherwise say the session cannot be reached. + * + * Why not SessionNotFoundError: pty:kill treats "Session not found" as proof the pty is + * already gone and synthesizes an exit, which is the same lie by another route. This one + * means "still there, we just cannot reach its host", so the kill is reported as failed and + * ownership is kept for a retry. + */ +export function ownerForDaemonOwnedOperation( + owner: IPtyProvider, + fallback: IPtyProvider, + sessionId: string +): IPtyProvider { + if (owner === fallback && fallback.hasPty?.(sessionId) !== true) { + throw new TerminalSessionOwnerUnverifiedError(sessionId) + } + return owner +} + /** Probes providers for an id absent from the routing map and adopts the * first proven owner into the map. */ export function adoptOwningProvider(