fix: keep sessions list responsive without catalog

This commit is contained in:
Peter Steinberger
2026-04-29 09:49:54 +01:00
parent a4e92c0aa4
commit c881e0a176
2 changed files with 97 additions and 2 deletions

View File

@@ -103,12 +103,46 @@ import { assertValidParams } from "./validation.js";
type SessionsRuntimeModule = typeof import("./sessions.runtime.js");
let sessionsRuntimeModulePromise: Promise<SessionsRuntimeModule> | undefined;
let loggedSlowSessionsListCatalog = false;
const SESSIONS_LIST_MODEL_CATALOG_TIMEOUT_MS = 750;
function loadSessionsRuntimeModule(): Promise<SessionsRuntimeModule> {
sessionsRuntimeModulePromise ??= import("./sessions.runtime.js");
return sessionsRuntimeModulePromise;
}
async function loadOptionalSessionsListModelCatalog(
context: GatewayRequestContext,
): Promise<Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>> | undefined> {
let timeout: NodeJS.Timeout | undefined;
const timedOut = Symbol("sessions-list-model-catalog-timeout");
const timeoutPromise = new Promise<typeof timedOut>((resolve) => {
timeout = setTimeout(() => resolve(timedOut), SESSIONS_LIST_MODEL_CATALOG_TIMEOUT_MS);
timeout.unref?.();
});
try {
const result = await Promise.race([
context.loadGatewayModelCatalog().catch(() => undefined),
timeoutPromise,
]);
if (result === timedOut) {
if (!loggedSlowSessionsListCatalog) {
loggedSlowSessionsListCatalog = true;
context.logGateway.debug(
`sessions.list continuing without model catalog after ${SESSIONS_LIST_MODEL_CATALOG_TIMEOUT_MS}ms`,
);
}
return undefined;
}
return Array.isArray(result) ? result : undefined;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
function requireSessionKey(key: unknown, respond: RespondFn): string | null {
const raw =
typeof key === "string"
@@ -613,8 +647,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params;
const cfg = context.getRuntimeConfig();
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const loadedCatalog = await context.loadGatewayModelCatalog().catch(() => undefined);
const modelCatalog = Array.isArray(loadedCatalog) ? loadedCatalog : undefined;
const modelCatalog = await loadOptionalSessionsListModelCatalog(context);
const result = listSessionsFromStore({
cfg,
storePath,

View File

@@ -46,6 +46,16 @@ async function getSessionsHandlers() {
return (await import("./server-methods/sessions.js")).sessionsHandlers;
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const sessionCleanupMocks = vi.hoisted(() => ({
clearSessionQueues: vi.fn((keys: Array<string | undefined>) => {
const clearedKeys = Array.from(
@@ -832,6 +842,58 @@ describe("gateway server sessions", () => {
);
});
test("sessions.list does not block on slow model catalog discovery", async () => {
await createSessionStoreDir();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
vi.useFakeTimers();
try {
const deferredCatalog = createDeferred<never>();
const respond = vi.fn();
const sessionsHandlers = await getSessionsHandlers();
const { getRuntimeConfig } = await getGatewayConfigModule();
const request = sessionsHandlers["sessions.list"]({
req: {
type: "req",
id: "req-sessions-list-slow-catalog",
method: "sessions.list",
params: {},
},
params: {},
respond,
client: null,
isWebchatConnect: () => false,
context: {
getRuntimeConfig,
loadGatewayModelCatalog: vi.fn(() => deferredCatalog.promise),
logGateway: {
debug: vi.fn(),
},
} as never,
});
await vi.advanceTimersByTimeAsync(800);
await request;
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
sessions: expect.arrayContaining([expect.objectContaining({ key: "agent:main:main" })]),
}),
undefined,
);
} finally {
vi.useRealTimers();
}
});
test("sessions.changed mutation events include live usage metadata", async () => {
const { dir } = await createSessionStoreDir();
await fs.writeFile(