diff --git a/ui/src/components/terminal/terminal-connection.test.ts b/ui/src/components/terminal/terminal-connection.test.ts index bb90b3e04f19..27fcc360627e 100644 --- a/ui/src/components/terminal/terminal-connection.test.ts +++ b/ui/src/components/terminal/terminal-connection.test.ts @@ -970,4 +970,33 @@ describe("TerminalConnection", () => { expect(client.listenerCount()).toBe(0); expect(conn.size).toBe(0); }); + + // A reply that lands after panel teardown races a dead owner. Registering its + // stream would retain the sink forever and arm the liveness probe loop + // against the replaced client, so the owner must refuse post-dispose work. + it.each([ + ["attach", "terminal.attach"], + ["open", "terminal.open"], + ] as const)( + "a late %s reply after dispose() leaves no resurrected stream or liveness probes", + (kind, method) => + withFakeTimers(async () => { + const { client, conn } = makeHarness(); + const response = createDeferred(); + deferRequest(client, method, response); + const settle = + kind === "attach" + ? conn.attach("s1", testSink()) + : conn.open({ cols: 80, rows: 24 }, testSink()); + // Panel teardown (reconnect or element removal) discards the connection + // while the RPC is still in flight. + conn.dispose(); + response.resolve({ ...sessionResult(), buffer: "replayed\n" }); + await expect(settle).resolves.toMatchObject({ sessionId: "s1" }); + expect(conn.size).toBe(0); + expect(client.listenerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(IDLE_PLUS_PROBE_MS); + expect(client.requests.filter((request) => request.method === "terminal.list")).toEqual([]); + }), + ); }); diff --git a/ui/src/components/terminal/terminal-connection.ts b/ui/src/components/terminal/terminal-connection.ts index 1b7f70fb7dfd..f886eda7fe98 100644 --- a/ui/src/components/terminal/terminal-connection.ts +++ b/ui/src/components/terminal/terminal-connection.ts @@ -140,6 +140,10 @@ export class TerminalConnection { // capped buffer becomes a detectable gap instead of silent output loss. private readonly pending = new Map>(); private unsubscribe: (() => void) | null = null; + // Fence for replies that outlive dispose(): without it a late open/attach + // would resurrect stream state and re-arm the liveness loop on a connection + // whose panel is gone or was replaced by a reconnect. + private disposed = false; private pendingOpenCount = 0; private livenessTimer: ReturnType | null = null; private livenessProbeInFlight = false; @@ -244,6 +248,9 @@ export class TerminalConnection { } throw new TerminalOpenUnusableSessionError(missingField); } + if (this.disposed) { + return result; + } const stream = this.setStream(result.sessionId, sink, { seqMode: "unknown", expectedSeq: 0, @@ -268,6 +275,9 @@ export class TerminalConnection { } const offset = typeof result.seq === "number" && Number.isSafeInteger(result.seq) ? result.seq : null; + if (this.disposed) { + return result; + } const stream = this.setStream(sessionId, sink, { seqMode: offset === null ? "counter" : "offset", expectedSeq: offset, @@ -640,6 +650,7 @@ export class TerminalConnection { } dispose(): void { + this.disposed = true; for (const stream of this.streams.values()) { stream.abort.abort(); }