From d75164c46d43b3fd3c32c69f1820bccdeabbe54f Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Sat, 22 Aug 2026 22:15:46 -0300 Subject: [PATCH] perf(ui): stop long-lived request and session caches from growing forever (#127818) * perf(ui): bound long-lived request and session state * fix(gateway-client): prevent request id collisions * fix(ui): preserve ordering across agent caches * fix(ui): preserve session order after pruning * fix(ui): retain cached session order during loading * fix(ui): evict removed agent session caches * test(ui): cover canonical session cache pruning --- .../src/pending-request.test.ts | 24 +++++ .../gateway-client/src/pending-request.ts | 23 ++--- .../src/protocol-client.request.test.ts | 49 ++++++--- ui/src/api/gateway.node.test.ts | 6 +- ui/src/api/gateway.ts | 28 ++++-- .../session-data-controller-events.test.ts | 99 +++++++++++++++++++ .../session-data-controller-events.ts | 77 ++++++++++++++- ...sion-data-controller.event-refresh.test.ts | 82 +++++++++++++-- ui/src/components/session-data-controller.ts | 25 +++-- .../lib/sessions/connection-hydration.test.ts | 22 ++--- 10 files changed, 356 insertions(+), 79 deletions(-) create mode 100644 packages/gateway-client/src/pending-request.test.ts create mode 100644 ui/src/components/session-data-controller-events.test.ts diff --git a/packages/gateway-client/src/pending-request.test.ts b/packages/gateway-client/src/pending-request.test.ts new file mode 100644 index 000000000000..d5f3810ee72f --- /dev/null +++ b/packages/gateway-client/src/pending-request.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { GatewayPendingRequests } from "./pending-request.js"; + +describe("GatewayPendingRequests", () => { + it("does not retain settled IDs for the socket generation", async () => { + let requestId = 0; + const requests = new GatewayPendingRequests({ + createRequestId: () => `request-${requestId++}`, + nowMs: () => 0, + }); + const sender = { + send: () => { + throw new Error("synthetic send failure"); + }, + }; + + for (let index = 0; index < 100; index += 1) { + await requests.request(sender, "bounded", {}, { timeoutMs: null }).catch(() => undefined); + } + + const retained = (requests as unknown as { retiredIds?: ReadonlySet }).retiredIds; + expect(retained?.size ?? 0).toBe(0); + }); +}); diff --git a/packages/gateway-client/src/pending-request.ts b/packages/gateway-client/src/pending-request.ts index 50b417fad775..080d43864a31 100644 --- a/packages/gateway-client/src/pending-request.ts +++ b/packages/gateway-client/src/pending-request.ts @@ -46,8 +46,7 @@ type GatewayPendingRequestsOptions = { /** Owns request deadlines, correlation, settlement, and generation-scoped IDs. */ export class GatewayPendingRequests { private readonly pending = new Map(); - private readonly retiredIds = new Set(); - private collisionSuffix = 0; + private requestSequence = 0; constructor(private readonly opts: GatewayPendingRequestsOptions) {} @@ -101,7 +100,6 @@ export class GatewayPendingRequests { return false; } this.pending.delete(id); - this.retiredIds.add(id); cleanup(); this.finishTiming(id, pending, false, errorCode); return true; @@ -186,23 +184,14 @@ export class GatewayPendingRequests { pending.reject(error); } this.pending.clear(); - // IDs are tombstoned only for one socket generation. Retired socket frames - // are fenced by GatewayProtocolClient before a replacement generation runs. - this.retiredIds.clear(); - this.collisionSuffix = 0; + // Request sequences belong to one socket generation. Retired socket frames + // are fenced by GatewayProtocolClient before the sequence restarts. + this.requestSequence = 0; } private allocateRequestId(): string { - const id = this.opts.createRequestId(); - if (!this.pending.has(id) && !this.retiredIds.has(id)) { - return id; - } - let uniqueId: string; - do { - this.collisionSuffix += 1; - uniqueId = `${id}:${this.collisionSuffix}`; - } while (this.pending.has(uniqueId) || this.retiredIds.has(uniqueId)); - return uniqueId; + this.requestSequence += 1; + return `${this.requestSequence}:${this.opts.createRequestId()}`; } private finishTiming( diff --git a/packages/gateway-client/src/protocol-client.request.test.ts b/packages/gateway-client/src/protocol-client.request.test.ts index b25e35ec1316..711b042abe80 100644 --- a/packages/gateway-client/src/protocol-client.request.test.ts +++ b/packages/gateway-client/src/protocol-client.request.test.ts @@ -210,20 +210,45 @@ describe("GatewayProtocolClient requests", () => { await expect(aborted).rejects.toThrow("gateway request aborted for aborted"); const replacement = client.request("replacement", {}, { timeoutMs: null }); - expect(latestFrame(connection)).toMatchObject({ id: "same-id:1", method: "replacement" }); - respond(connection, "same-id", { stale: true }); + expect(latestFrame(connection)).toMatchObject({ id: "2:same-id", method: "replacement" }); + respond(connection, "1:same-id", { stale: true }); expect(client.hasPendingRequests).toBe(true); - respond(connection, "same-id:1", { current: true }); + respond(connection, "2:same-id", { current: true }); await expect(replacement).resolves.toEqual({ current: true }); await expect(client.request("send.failure", {}, { timeoutMs: null })).rejects.toThrow( "synthetic send failure", ); - expect(latestFrame(connection)).toMatchObject({ id: "same-id:2", method: "send.failure" }); + expect(latestFrame(connection)).toMatchObject({ id: "3:same-id", method: "send.failure" }); expect(client.hasPendingRequests).toBe(false); client.stop(); }); + it("keeps concurrent requests distinct when generated IDs contain sequence suffixes", async () => { + const generatedIds = ["same-id:1", "same-id"]; + const { client, connections } = createRequestHarness({ + createRequestId: () => generatedIds.shift() ?? "same-id", + }); + const connection = connections[0]; + if (!connection) { + throw new Error("expected request connection"); + } + + const first = client.request("first", {}, { timeoutMs: null }); + const second = client.request("second", {}, { timeoutMs: null }); + const [firstFrame, secondFrame] = connection.frames; + if (!firstFrame || !secondFrame) { + throw new Error("expected concurrent request frames"); + } + expect(firstFrame.id).not.toBe(secondFrame.id); + + respond(connection, firstFrame.id, { request: "first" }); + respond(connection, secondFrame.id, { request: "second" }); + await expect(first).resolves.toEqual({ request: "first" }); + await expect(second).resolves.toEqual({ request: "second" }); + client.stop(); + }); + it("ignores late accepted and final replies after a timeout collision", async () => { vi.useFakeTimers(); const onAccepted = vi.fn(); @@ -242,14 +267,14 @@ describe("GatewayProtocolClient requests", () => { {}, { timeoutMs: null, expectFinal: true, onAccepted }, ); - expect(latestFrame(connection)).toMatchObject({ id: "same-id:1", method: "agent" }); - respond(connection, "same-id", { status: "accepted", runId: "old" }); - respond(connection, "same-id", { status: "ok", runId: "old" }); + expect(latestFrame(connection)).toMatchObject({ id: "2:same-id", method: "agent" }); + respond(connection, "1:same-id", { status: "accepted", runId: "old" }); + respond(connection, "1:same-id", { status: "ok", runId: "old" }); expect(onAccepted).not.toHaveBeenCalled(); expect(client.hasPendingRequests).toBe(true); - respond(connection, "same-id:1", { status: "accepted", runId: "new" }); - respond(connection, "same-id:1", { status: "ok", runId: "new" }); + respond(connection, "2:same-id", { status: "accepted", runId: "new" }); + respond(connection, "2:same-id", { status: "ok", runId: "new" }); await expect(replacement).resolves.toEqual({ status: "ok", runId: "new" }); expect(onAccepted).toHaveBeenCalledExactlyOnceWith({ status: "accepted", runId: "new" }); client.stop(); @@ -463,7 +488,7 @@ describe("GatewayProtocolClient requests", () => { client.stop(); }); - it("clears generation tombstones when the socket flushes", async () => { + it("restarts the request sequence when the socket flushes", async () => { vi.useFakeTimers(); const { client, connections } = createRequestHarness({ createRequestId: () => "same-id" }); const firstConnection = connections[0]; @@ -482,8 +507,8 @@ describe("GatewayProtocolClient requests", () => { throw new Error("expected replacement request connection"); } const replacement = client.request("second", {}, { timeoutMs: null }); - expect(latestFrame(secondConnection)).toMatchObject({ id: "same-id", method: "second" }); - respond(secondConnection, "same-id", { ok: true }); + expect(latestFrame(secondConnection)).toMatchObject({ id: "1:same-id", method: "second" }); + respond(secondConnection, "1:same-id", { ok: true }); await expect(replacement).resolves.toEqual({ ok: true }); client.stop(); }); diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 8ccbf2d341e1..303207a863e1 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -205,7 +205,7 @@ type ConnectFrame = { }; }; -const REQUEST_FRAME_ID = "00000000-0000-4000-8000-000000000000"; +const REQUEST_FRAME_ID = "2:00000000-0000-4000-8000-000000000000"; function requestFrameBytes(method: string, params?: unknown): number { const frame = @@ -1679,7 +1679,7 @@ describe("GatewayBrowserClient", () => { const { connectFrame } = await startConnect(client); - expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.id).toBe("1:req-insecure"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth).toEqual({ token: "shared-auth-token", @@ -1699,7 +1699,7 @@ describe("GatewayBrowserClient", () => { const { connectFrame } = await startConnect(client); - expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.id).toBe("1:req-insecure"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth).toEqual({ token: undefined, diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 39e4e0ee7678..78c74f3dd994 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -296,7 +296,24 @@ export class GatewayBrowserClient { constructor(private opts: GatewayBrowserClientOptions) { this.client = new GatewayProtocolClient({ - createSocket: (handlers) => createBrowserGatewaySocket(this.opts.url, handlers), + createSocket: (handlers) => { + this.maxPayloadBytes = undefined; + const socket = createBrowserGatewaySocket(this.opts.url, handlers); + return { + ...socket, + send: (data) => { + if ( + this.maxPayloadBytes !== undefined && + new TextEncoder().encode(data).byteLength > this.maxPayloadBytes + ) { + throw new Error( + "Request exceeds the Gateway payload limit. Shorten the message or remove one or more attachments and retry.", + ); + } + socket.send(data); + }, + }; + }, createRequestId: generateUUID, createRequestError: (error) => new GatewayRequestError({ @@ -654,15 +671,6 @@ export class GatewayBrowserClient { params?: unknown, options?: GatewayProtocolRequestOptions, ): Promise { - // The UUID request envelope adds 75 bytes with params, 61 when params is omitted. - const requestBytes = - new TextEncoder().encode(JSON.stringify([method, params])).byteLength + - (params === undefined ? 61 : 75); - if (this.maxPayloadBytes !== undefined && requestBytes > this.maxPayloadBytes) { - throw new Error( - "Request exceeds the Gateway payload limit. Shorten the message or remove one or more attachments and retry.", - ); - } return await this.client.request(method, params, options); } diff --git a/ui/src/components/session-data-controller-events.test.ts b/ui/src/components/session-data-controller-events.test.ts new file mode 100644 index 000000000000..fa68640b45ba --- /dev/null +++ b/ui/src/components/session-data-controller-events.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import type { SessionsListResult } from "../api/types.ts"; +import { compareSidebarSessionRowsByMode } from "./app-sidebar-session-navigation-logic.ts"; +import { publishSidebarSessionList } from "./session-data-controller-events.ts"; + +describe("publishSidebarSessionList", () => { + const createOwner = () => ({ + context: undefined, + sessionCreatedOrder: new Map(), + sessionResultsByAgent: {} as Record, + sessionsResult: null as SessionsListResult | null, + sessionsAgentId: null as string | null, + sessionsLoading: false, + sessionMutationError: null, + expandedAgentId: () => "main", + requestSessionDataUpdate: () => undefined, + }); + + const publish = (owner: ReturnType, agentId: string | null, keys: string[]) => + publishSidebarSessionList(owner, { + result: { + sessions: keys.map((key, index) => ({ key, kind: "direct", updatedAt: index })), + count: keys.length, + } as SessionsListResult, + agentId, + loading: false, + error: null, + }); + + it("keeps observed creation order only for rows in the current accumulated result", () => { + const owner = createOwner(); + + publish(owner, "main", ["first", "second"]); + publish(owner, "main", ["second", "third"]); + + expect([...owner.sessionCreatedOrder.keys()]).toEqual(["second", "third"]); + }); + + it("keeps observed order after pruning and adding a session", () => { + const owner = createOwner(); + + publish(owner, "main", ["removed", "z-retained"]); + publish(owner, "main", ["z-retained"]); + publish(owner, "main", ["z-retained", "a-added"]); + + const ordered = owner.sessionResultsByAgent.main?.sessions.toSorted((a, b) => + compareSidebarSessionRowsByMode({ + a, + b, + sortMode: "created", + owners: undefined, + createdOrder: owner.sessionCreatedOrder, + }), + ); + expect(ordered?.map((row) => row.key)).toEqual(["z-retained", "a-added"]); + }); + + it("keeps creation order for every retained agent result", () => { + const owner = createOwner(); + + publish(owner, "alpha", ["alpha-first", "alpha-second"]); + publish(owner, "beta", ["beta-first"]); + publish(owner, "alpha", ["alpha-first", "alpha-second"]); + + expect([...owner.sessionCreatedOrder.keys()]).toEqual([ + "alpha-first", + "alpha-second", + "beta-first", + ]); + }); + + it("keeps cached agent order while an uncached agent has no result", () => { + const owner = createOwner(); + + publish(owner, "alpha", ["alpha-first", "alpha-second"]); + publishSidebarSessionList(owner, { + result: null, + agentId: "beta", + loading: true, + error: null, + }); + + expect([...owner.sessionCreatedOrder.keys()]).toEqual(["alpha-first", "alpha-second"]); + }); + + it("preserves promoted order for an unscoped canonical result", () => { + const owner = createOwner(); + owner.sessionCreatedOrder.set("first", 1); + owner.sessionCreatedOrder.set("second", 0); + + publish(owner, null, ["first", "second"]); + + expect([...owner.sessionCreatedOrder]).toEqual([ + ["first", 1], + ["second", 0], + ]); + }); +}); diff --git a/ui/src/components/session-data-controller-events.ts b/ui/src/components/session-data-controller-events.ts index 7799af1508ed..488ce1487baf 100644 --- a/ui/src/components/session-data-controller-events.ts +++ b/ui/src/components/session-data-controller-events.ts @@ -1,6 +1,7 @@ import type { RouteId } from "../app-route-paths.ts"; import type { ApplicationContext } from "../app/context.ts"; import { readPresenceEntries, type PresencePayload } from "../app/user-profile.ts"; +import type { AgentCapability } from "../lib/agents/index.ts"; import type { SessionCapability, SessionListSnapshot } from "../lib/sessions/index.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { @@ -20,6 +21,63 @@ type SidebarSessionListOwner = { requestSessionDataUpdate(): void; }; +function pruneSidebarSessionOrder( + owner: SidebarSessionListOwner, + retainedResults: readonly NonNullable[], +): void { + const visibleKeys = new Set( + retainedResults.flatMap((result) => result.sessions.map((row) => row.key).filter(Boolean)), + ); + for (const key of owner.sessionCreatedOrder.keys()) { + if (!visibleKeys.has(key)) { + owner.sessionCreatedOrder.delete(key); + } + } +} + +function pruneSidebarAgentSessionCaches( + owner: SidebarSessionListOwner, + agentIds: readonly string[], +): void { + const retainedAgentIds = new Set(agentIds.map(normalizeAgentId)); + for (const agentId of Object.keys(owner.sessionResultsByAgent)) { + if (!retainedAgentIds.has(agentId)) { + delete owner.sessionResultsByAgent[agentId]; + } + } + if (owner.sessionsAgentId && !retainedAgentIds.has(normalizeAgentId(owner.sessionsAgentId))) { + owner.sessionsResult = null; + owner.sessionsAgentId = null; + } + const retainedResults = Object.values(owner.sessionResultsByAgent); + if (owner.sessionsResult) { + retainedResults.push(owner.sessionsResult); + } + pruneSidebarSessionOrder(owner, retainedResults); +} + +export function subscribeSidebarAgentSessionCaches( + agents: AgentCapability, + owner: SidebarSessionListOwner, + notify: () => void, +): () => void { + const synchronize = () => { + const roster = agents.state.agentsList; + // A null roster is transient during reconnect; only a concrete list can evict agent caches. + if (roster) { + pruneSidebarAgentSessionCaches( + owner, + roster.agents.map((agent) => agent.id), + ); + } + }; + synchronize(); + return agents.subscribe(() => { + synchronize(); + notify(); + }); +} + function filteredSidebarSessionQuery(agentId: string, archivedFilter: SidebarSessionStatusFilter) { return { agentId, @@ -36,14 +94,23 @@ export function publishSidebarSessionList( ): void { owner.sessionsResult = snapshot.result; owner.sessionsAgentId = snapshot.agentId; - for (const row of snapshot.result?.sessions ?? []) { - if (row.key && !owner.sessionCreatedOrder.has(row.key)) { - owner.sessionCreatedOrder.set(row.key, owner.sessionCreatedOrder.size); - } - } + const sessions = snapshot.result?.sessions ?? []; if (snapshot.result && snapshot.agentId) { owner.sessionResultsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result; } + const retainedResults = snapshot.result + ? [snapshot.result, ...Object.values(owner.sessionResultsByAgent)] + : Object.values(owner.sessionResultsByAgent); + pruneSidebarSessionOrder(owner, retainedResults); + let nextCreatedOrder = 0; + for (const order of owner.sessionCreatedOrder.values()) { + nextCreatedOrder = Math.max(nextCreatedOrder, order + 1); + } + for (const row of sessions) { + if (row.key && !owner.sessionCreatedOrder.has(row.key)) { + owner.sessionCreatedOrder.set(row.key, nextCreatedOrder++); + } + } } export function subscribeFilteredSidebarSessions( diff --git a/ui/src/components/session-data-controller.event-refresh.test.ts b/ui/src/components/session-data-controller.event-refresh.test.ts index 9ae63d16cf71..1567cd365922 100644 --- a/ui/src/components/session-data-controller.event-refresh.test.ts +++ b/ui/src/components/session-data-controller.event-refresh.test.ts @@ -25,6 +25,13 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo kind: "direct" as const, updatedAt: index + 1, })); + const resultForKeys = (keys: string[]) => ({ + ts: 1, + path: "", + count: keys.length, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: keys.map((key) => ({ key, kind: "direct" as const })), + }); const list = vi.fn(async (options?: Parameters[0]) => { const offset = options?.offset ?? 0; const limit = options?.limit ?? 60; @@ -86,19 +93,24 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo const sessions = createSessionCapability(gateway); let selectedAgentId = "main"; let selectedStatusFilter = statusFilter; + const agentsState = { + connected: true, + client, + agentsList: { + defaultId: "main", + agents: [{ id: "main" }, { id: "research" }], + } as ApplicationContext["agents"]["state"]["agentsList"], + }; + const agentListeners = new Set<(state: ApplicationContext["agents"]["state"]) => void>(); const context = { gateway, sessions, agents: { - state: { - connected: true, - client, - agentsList: { - defaultId: "main", - agents: [{ id: "main" }, { id: "research" }], - }, + state: agentsState, + subscribe(listener: (state: ApplicationContext["agents"]["state"]) => void) { + agentListeners.add(listener); + return () => agentListeners.delete(listener); }, - subscribe: () => () => undefined, }, agentSelection: { get state() { @@ -127,6 +139,7 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo return { controller, list, + resultForKeys, selectAgent: (agentId: string) => { selectedAgentId = agentId; controller.synchronizeSessionScope(); @@ -150,10 +163,63 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo listener(event); } }, + publishAgentRoster: (agentIds: string[] | null) => { + agentsState.agentsList = agentIds + ? { + defaultId: "main", + mainKey: "main", + scope: "global", + agents: agentIds.map((id) => ({ id })), + } + : null; + for (const listener of agentListeners) { + listener(agentsState as ApplicationContext["agents"]["state"]); + } + }, }; } describe("filtered sidebar session event refresh", () => { + it("evicts cached sessions when an agent leaves the authoritative roster", () => { + const { controller, publishAgentRoster, resultForKeys } = + createFilteredSessionController("all"); + controller.hostConnected(); + controller.sessionResultsByAgent = { + main: resultForKeys(["agent:main:kept"]), + research: resultForKeys(["agent:research:removed"]), + }; + controller.sessionsResult = controller.sessionResultsByAgent.research ?? null; + controller.sessionsAgentId = "research"; + controller.sessionCreatedOrder = new Map([ + ["agent:main:kept", 0], + ["agent:research:removed", 1], + ]); + + publishAgentRoster(null); + expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main", "research"]); + + publishAgentRoster(["main"]); + expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main"]); + expect(controller.sessionsResult).toBeNull(); + expect(controller.sessionsAgentId).toBeNull(); + expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:kept"]); + controller.hostDisconnected(); + }); + + it("retains the current canonical result outside the per-agent cache", () => { + const { controller, publishAgentRoster, resultForKeys } = + createFilteredSessionController("all"); + controller.hostConnected(); + controller.sessionsResult = resultForKeys(["agent:main:current"]); + controller.sessionsAgentId = "main"; + controller.sessionCreatedOrder = new Map([["agent:main:current", 0]]); + + publishAgentRoster(["main"]); + + expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:current"]); + controller.hostDisconnected(); + }); + it.each(["archived", "all"] as const)( "refreshes the %s list once for duplicate remote session events", async (statusFilter) => { diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index 776456d9f8ca..4e1f8c620142 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -48,6 +48,7 @@ import { import { publishSidebarSessionList, refreshSidebarSessionList, + subscribeSidebarAgentSessionCaches, subscribeFilteredSidebarSessions, subscribeSessionDataGatewayEvents, } from "./session-data-controller-events.ts"; @@ -141,7 +142,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog ) .watch( () => this.context?.agents, - (agents, notify) => agents.subscribe(notify), + (agents, notify) => subscribeSidebarAgentSessionCaches(agents, this, notify), ) .watch( () => this.context?.agentSelection, @@ -201,10 +202,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog this.gatewayConnected = false; this.retireSessionCatalogData(); this.scroll.dispose(); - if (this.activeSessionLineageRetryTimer) { - globalThis.clearTimeout(this.activeSessionLineageRetryTimer); - this.activeSessionLineageRetryTimer = null; - } + this.clearActiveSessionLineageRetry(); this.subscriptions.hostDisconnected(); } @@ -365,6 +363,13 @@ export class SessionDataController implements ReactiveController, SessionCatalog return this.scroll.state; } + private clearActiveSessionLineageRetry(): void { + if (this.activeSessionLineageRetryTimer) { + globalThis.clearTimeout(this.activeSessionLineageRetryTimer); + this.activeSessionLineageRetryTimer = null; + } + } + updateSessionsScrollState(element: HTMLElement): void { this.scroll.update(element); } @@ -387,10 +392,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog } this.activeSessionLineageLoaded = false; this.activeSessionLineageRequest = null; - if (this.activeSessionLineageRetryTimer) { - globalThis.clearTimeout(this.activeSessionLineageRetryTimer); - this.activeSessionLineageRetryTimer = null; - } + this.clearActiveSessionLineageRetry(); } private readonly updateSessions = (sessions: SessionCapability) => { @@ -624,10 +626,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog this.activeSessionLineageRequest = null; this.activeSessionLineageRoot = null; this.activeSessionLineageSelectedRow = null; - if (this.activeSessionLineageRetryTimer) { - globalThis.clearTimeout(this.activeSessionLineageRetryTimer); - this.activeSessionLineageRetryTimer = null; - } + this.clearActiveSessionLineageRetry(); this.notify(); } const { gateway, sessions } = this.context ?? {}; diff --git a/ui/src/lib/sessions/connection-hydration.test.ts b/ui/src/lib/sessions/connection-hydration.test.ts index 615ebbc3ccf9..f325954cc5b3 100644 --- a/ui/src/lib/sessions/connection-hydration.test.ts +++ b/ui/src/lib/sessions/connection-hydration.test.ts @@ -383,31 +383,31 @@ describe("session connection hydration", () => { try { connect(); - expect(sent).toEqual([{ id: "request", method: "sessions.subscribe" }]); + expect(sent).toEqual([{ id: "1:request", method: "sessions.subscribe" }]); await vi.advanceTimersByTimeAsync(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS); - expect(sent).toContainEqual({ id: "request:1", method: "sessions.list" }); - respond("request:1", initialResult); + expect(sent).toContainEqual({ id: "2:request", method: "sessions.list" }); + respond("2:request", initialResult); await vi.advanceTimersByTimeAsync(0); expect(sessions.state.result).toEqual(initialResult); await vi.advanceTimersByTimeAsync(500); - expect(sent).toContainEqual({ id: "request:2", method: "sessions.subscribe" }); + expect(sent).toContainEqual({ id: "3:request", method: "sessions.subscribe" }); - respond("request", { status: "accepted" }); - respond("request", { subscribed: true }); + respond("1:request", { status: "accepted" }); + respond("1:request", { subscribed: true }); await vi.advanceTimersByTimeAsync(0); expect(sessions.state.error).not.toBeNull(); expect(sent.filter(({ method }) => method === "sessions.list")).toHaveLength(1); - respond("request:2", { subscribed: true }); + respond("3:request", { subscribed: true }); await vi.advanceTimersByTimeAsync(0); - expect(sent).toContainEqual({ id: "request:3", method: "sessions.list" }); - respond("request:3", recoveredResult); + expect(sent).toContainEqual({ id: "4:request", method: "sessions.list" }); + respond("4:request", recoveredResult); await vi.advanceTimersByTimeAsync(0); - respond("request", { status: "accepted" }); - respond("request", { subscribed: true }); + respond("1:request", { status: "accepted" }); + respond("1:request", { subscribed: true }); await vi.advanceTimersByTimeAsync(0); expect(sessions.state.error).toBeNull();