fix(ui): refuse late open/attach replies after terminal connection dispose (#128147)

This commit is contained in:
Vyctor H. Brzezowski
2026-08-23 08:05:12 -03:00
committed by GitHub
parent 9046ecea73
commit cbc89fc5a1
2 changed files with 40 additions and 0 deletions
@@ -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<TestSessionResult & { buffer: string }>();
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([]);
}),
);
});
@@ -140,6 +140,10 @@ export class TerminalConnection {
// capped buffer becomes a detectable gap instead of silent output loss.
private readonly pending = new Map<string, BoundedBuffer<PendingEvent>>();
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<typeof setTimeout> | 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();
}