mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): refuse late open/attach replies after terminal connection dispose (#128147)
This commit is contained in:
committed by
GitHub
parent
9046ecea73
commit
cbc89fc5a1
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user