From 4e398bbde9f46529241eb0a83b568c3cf9139092 Mon Sep 17 00:00:00 2001 From: why-tomato Date: Tue, 1 Sep 2026 14:23:33 +0800 Subject: [PATCH 1/3] fix: accept in-flight dispatch readiness at deadline --- app/api/session/session-dispatch-service.ts | 6 +- tests/session-prewarm.test.mjs | 119 ++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/app/api/session/session-dispatch-service.ts b/app/api/session/session-dispatch-service.ts index 44075ff1d..e095c201d 100644 --- a/app/api/session/session-dispatch-service.ts +++ b/app/api/session/session-dispatch-service.ts @@ -657,13 +657,13 @@ async function waitForReusableAgentParticipant( allowAnonymousLiveKitAgentFallback: true, ...readiness, }); - if (remainingDispatchTime(getDeadline()) <= 0) { - return null; - } if (participant) { throwIfSessionCancelled(session); return participant; } + if (remainingDispatchTime(getDeadline()) <= 0) { + return null; + } const waitMs = Math.min(pollMs, remainingDispatchTime(getDeadline())); if (waitMs <= 0) { return null; diff --git a/tests/session-prewarm.test.mjs b/tests/session-prewarm.test.mjs index 243ac835e..ac5117f2b 100644 --- a/tests/session-prewarm.test.mjs +++ b/tests/session-prewarm.test.mjs @@ -658,6 +658,125 @@ test('dispatch deadline never starts participant IO, dispatch creation, or sleep } }); +test('dispatch accepts a readiness query started before the deadline when it returns ready after it', async () => { + const originalNow = Date.now; + let now = 1_000; + const deadline = 2_000; + const agentName = 'frontdesk-browser-agent-late-ready'; + let participantReads = 0; + let deleteDispatchCalls = 0; + Date.now = () => now; + + try { + const result = await dispatchRoomSession( + { + roomName: 'voice_assistant_room_late_ready', + sessionId: 'late-ready', + agentName, + readiness: { requireRoomVideoInputReady: true }, + }, + { + dispatchClient: { + async createDispatch() { + return { id: 'dispatch-late-ready' }; + }, + async deleteDispatch() { + deleteDispatchCalls += 1; + }, + }, + roomClient: { + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + if (participantReads === 2) { + now = deadline - 1; + return readyParticipants(agentName); + } + + assert.equal(now, deadline - 1); + now = deadline + 1; + return readyParticipants(agentName, { videoReady: true }); + }, + async deleteRoom() {}, + }, + dispatchDeadlineMs: deadline, + dispatchPollMs: 100, + sleep: async () => { + assert.fail('ready participant queries should not sleep'); + }, + } + ); + + assert.equal(result.dispatchId, 'dispatch-late-ready'); + assert.equal(result.agentParticipant.identity, 'agent-ready'); + assert.equal(participantReads, 3); + assert.equal(deleteDispatchCalls, 0); + } finally { + Date.now = originalNow; + } +}); + +test('dispatch stops after a pre-deadline readiness query returns not-ready after the deadline', async () => { + const originalNow = Date.now; + let now = 1_000; + const deadline = 2_000; + const agentName = 'frontdesk-browser-agent-late-not-ready'; + let participantReads = 0; + let sleepCalls = 0; + Date.now = () => now; + + try { + await assert.rejects( + dispatchRoomSession( + { + roomName: 'voice_assistant_room_late_not_ready', + sessionId: 'late-not-ready', + agentName, + readiness: { requireRoomVideoInputReady: true }, + }, + { + dispatchClient: { + async createDispatch() { + return { id: 'dispatch-late-not-ready' }; + }, + async deleteDispatch() {}, + }, + roomClient: { + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + if (participantReads === 2) { + now = deadline - 1; + return readyParticipants(agentName); + } + + assert.equal(now, deadline - 1); + now = deadline + 1; + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }, + dispatchDeadlineMs: deadline, + dispatchPollMs: 100, + sleep: async () => { + sleepCalls += 1; + }, + } + ), + /agent session and required room inputs did not become ready/ + ); + + assert.equal(participantReads, 3); + assert.equal(sleepCalls, 0); + } finally { + Date.now = originalNow; + } +}); + test('room timeout cannot create a room after a delayed list operation finishes', async () => { const originalNow = Date.now; Date.now = () => 1_000; From f271c71ea010455f97b163490b8f1d0626e8f460 Mon Sep 17 00:00:00 2001 From: why-tomato Date: Tue, 1 Sep 2026 14:48:07 +0800 Subject: [PATCH 2/3] fix: enforce shared dispatch readiness deadline --- app/api/session/session-dispatch-service.ts | 1 + tests/session-prewarm.test.mjs | 75 +++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/app/api/session/session-dispatch-service.ts b/app/api/session/session-dispatch-service.ts index e095c201d..e02ed04cc 100644 --- a/app/api/session/session-dispatch-service.ts +++ b/app/api/session/session-dispatch-service.ts @@ -513,6 +513,7 @@ async function createAgentDispatchWithRetry( session, sleepFn ); + throwIfDeadlineExpired(getDeadline(), 'agent dispatch readiness'); if (agentParticipant) { throwIfSessionCancelled(session); markRoomSessionRunning(session); diff --git a/tests/session-prewarm.test.mjs b/tests/session-prewarm.test.mjs index ac5117f2b..82469e691 100644 --- a/tests/session-prewarm.test.mjs +++ b/tests/session-prewarm.test.mjs @@ -777,6 +777,81 @@ test('dispatch stops after a pre-deadline readiness query returns not-ready afte } }); +test('prewarm timeout cleans up a dispatch whose in-flight readiness query returns late', async () => { + const roomName = 'voice_assistant_room_prewarm_late_readiness'; + const sessionId = 'prewarm-late-readiness'; + const agentName = 'frontdesk-browser-agent-prewarm-late-readiness'; + let participantReads = 0; + let deleteDispatchCalls = 0; + let markLateReadStarted; + let releaseLateRead; + const lateReadStarted = new Promise((resolve) => { + markLateReadStarted = resolve; + }); + const lateReadGate = new Promise((resolve) => { + releaseLateRead = resolve; + }); + + const pending = prewarmRoomSession( + { roomName, sessionId, agentName }, + { + dispatchClient: { + async createDispatch() { + return { id: 'dispatch-prewarm-late-readiness' }; + }, + async deleteDispatch() { + deleteDispatchCalls += 1; + }, + }, + roomClient: { + async listRooms() { + return [{ name: roomName }]; + }, + async createRoom() { + assert.fail('the existing room should be reused'); + }, + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + markLateReadStarted(); + await lateReadGate; + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }, + waitForAgentWorkerReady: async () => ({ + state: 'ready', + agentName, + workerId: 'AW_prewarm_late_readiness', + registeredAt: '2026-09-01T00:00:00Z', + waitedMs: 0, + }), + dispatchTimeoutMs: 100, + dispatchPollMs: 1, + } + ); + + await lateReadStarted; + let failure; + await assert.rejects(pending, (error) => { + failure = error; + assert.equal(error instanceof PrewarmRoomSessionError, true); + assert.match(error.message, /prewarm deadline expired during dispatch_readiness/); + return true; + }); + + assert.equal(deleteDispatchCalls, 0); + assert.ok(failure.retryReady); + releaseLateRead(); + await failure.retryReady; + + assert.equal(participantReads, 2); + assert.equal(deleteDispatchCalls, 1); + assert.equal(getRoomSessionSnapshot(roomName)?.state, 'starting'); +}); + test('room timeout cannot create a room after a delayed list operation finishes', async () => { const originalNow = Date.now; Date.now = () => 1_000; From 148df088fd485544ca42d0c6c0c62721543e03a6 Mon Sep 17 00:00:00 2001 From: why-tomato Date: Tue, 1 Sep 2026 18:13:19 +0800 Subject: [PATCH 3/3] fix: preserve active dispatch callers at deadline --- app/api/session/session-dispatch-service.ts | 129 +++++++-- tests/session-prewarm.test.mjs | 279 +++++++++++++++++++- 2 files changed, 390 insertions(+), 18 deletions(-) diff --git a/app/api/session/session-dispatch-service.ts b/app/api/session/session-dispatch-service.ts index e02ed04cc..4c96a565f 100644 --- a/app/api/session/session-dispatch-service.ts +++ b/app/api/session/session-dispatch-service.ts @@ -30,6 +30,7 @@ type RoomClient = Pick< type DispatchDependencies = { dispatchClient?: DispatchClient; roomClient?: RoomClient; + abortSignal?: AbortSignal; dispatchTimeoutMs?: number; dispatchDeadlineMs?: number; dispatchRetryMs?: number; @@ -41,6 +42,10 @@ type DispatchDependencies = { ) => Promise; }; +type DispatchCaller = { + abortSignal?: AbortSignal; +}; + export type DispatchRoomSessionRequest = { roomName: string; sessionId: string; @@ -60,8 +65,13 @@ export class RoomSessionCancelledError extends Error { type InFlightDispatch = { operation: Promise>; session: RoomSessionToken; - callers: number; + callers: Set; deadline: { value: number }; + dispatchClient: DispatchClient; + accepted: boolean; + cleanup?: Promise; + drained: Promise; + resolveDrained: () => void; }; type InFlightDispatches = Map; @@ -120,20 +130,46 @@ class PrewarmDeadlineError extends Error { } } +class DispatchCallerAbandonedError extends Error { + constructor() { + super('dispatch caller abandoned'); + this.name = 'DispatchCallerAbandonedError'; + } +} + export async function dispatchRoomSession( request: DispatchRoomSessionRequest, dependencies: DispatchDependencies = {} ) { + if (dependencies.abortSignal?.aborted) { + throw new DispatchCallerAbandonedError(); + } const startedAt = Date.now(); const callerDeadline = resolveDispatchDeadline(dependencies, startedAt); if (callerDeadline <= startedAt) { throw new Error('agent dispatch deadline expired before dispatch'); } const key = `${request.sessionId}\u0000${request.roomName}\u0000${request.agentName}`; + const caller: DispatchCaller = { abortSignal: dependencies.abortSignal }; let inFlight = inFlightDispatches.get(key); + while (inFlight?.cleanup) { + await inFlight.drained; + if (dependencies.abortSignal?.aborted) { + throw new DispatchCallerAbandonedError(); + } + if (callerDeadline <= Date.now()) { + throw new Error('agent dispatch deadline expired before dispatch'); + } + inFlight = inFlightDispatches.get(key); + } if (!inFlight) { const clients = resolveClients(dependencies); const deadline = { value: callerDeadline }; + const callers = new Set([caller]); + let resolveDrained = () => {}; + const drained = new Promise((resolve) => { + resolveDrained = resolve; + }); // Dispatch creation is shared by identity. Each caller waits for its own // readiness contract below, while the shared operation keeps the longest // timeout budget of all concurrent callers. @@ -150,29 +186,48 @@ export async function dispatchRoomSession( () => deadline.value ), session, - callers: 0, + callers, deadline, + dispatchClient: clients.dispatchClient, + accepted: false, + drained, + resolveDrained, }; inFlightDispatches.set(key, inFlight); } else { inFlight.deadline.value = Math.max(inFlight.deadline.value, callerDeadline); } - inFlight.callers += 1; + inFlight.callers.add(caller); + let dispatch: Record | undefined; try { - const dispatch = await inFlight.operation; - return await waitForRequestedRoomSessionReadiness( + const sharedDispatch = await inFlight.operation; + dispatch = sharedDispatch; + throwIfDispatchCallerAbandoned(() => isDispatchCallerActive(inFlight, caller)); + const result = await waitForRequestedRoomSessionReadiness( request, dependencies, - dispatch, + sharedDispatch, inFlight.session, - startedAt + startedAt, + () => isDispatchCallerActive(inFlight, caller) ); + throwIfSessionCancelled(inFlight.session); + throwIfDispatchCallerAbandoned(() => isDispatchCallerActive(inFlight, caller)); + inFlight.accepted = true; + markRoomSessionRunning(inFlight.session); + return result; + } catch (error) { + if (dispatch && error instanceof DispatchCallerAbandonedError) { + await cleanupAbandonedDispatch(inFlight, dispatch, request.roomName); + } + throw error; } finally { - inFlight.callers -= 1; - if (inFlight.callers === 0 && inFlightDispatches.get(key) === inFlight) { + inFlight.callers.delete(caller); + if (inFlight.callers.size === 0 && inFlightDispatches.get(key) === inFlight) { finishRoomSessionDispatch(inFlight.session); inFlightDispatches.delete(key); + inFlight.resolveDrained(); } } } @@ -182,8 +237,10 @@ async function waitForRequestedRoomSessionReadiness( dependencies: DispatchDependencies, dispatch: Record, session: RoomSessionToken, - startedAt: number + startedAt: number, + isCallerActive: () => boolean ) { + throwIfDispatchCallerAbandoned(isCallerActive); const readiness = request.readiness ?? {}; if ( readiness.requireAgentSessionReady !== true && @@ -203,13 +260,14 @@ async function waitForRequestedRoomSessionReadiness( () => deadline, dependencies.dispatchPollMs || readPositiveIntEnv('AGENT_DISPATCH_POLL_MS', 200), session, - dependencies.sleep || sleep + dependencies.sleep || sleep, + isCallerActive ); if (!participant) { throw new Error('agent session and required room inputs did not become ready'); } throwIfSessionCancelled(session); - markRoomSessionRunning(session); + throwIfDispatchCallerAbandoned(isCallerActive); return { ...dispatch, agentParticipant: summarizeAgentParticipant(participant), @@ -307,6 +365,7 @@ export async function prewarmRoomSession( { ...dependencies, ...roomAndClients.clients, + abortSignal: prewarmAbortController.signal, dispatchDeadlineMs: prewarmDeadline, } ); @@ -478,15 +537,14 @@ async function createAgentDispatchWithRetry( reusableAgentOptions ); throwIfSessionCancelled(session); - throwIfDeadlineExpired(getDeadline(), 'agent dispatch'); if (alreadyJoined) { - markRoomSessionRunning(session); return { attempts, alreadyJoined: true, agentParticipant: summarizeAgentParticipant(alreadyJoined), }; } + throwIfDeadlineExpired(getDeadline(), 'agent dispatch'); if (!dispatchId) { throwIfDeadlineExpired(getDeadline(), 'agent dispatch creation'); @@ -513,10 +571,8 @@ async function createAgentDispatchWithRetry( session, sleepFn ); - throwIfDeadlineExpired(getDeadline(), 'agent dispatch readiness'); if (agentParticipant) { throwIfSessionCancelled(session); - markRoomSessionRunning(session); return { attempts, dispatchId, @@ -647,10 +703,12 @@ async function waitForReusableAgentParticipant( getDeadline: () => number, pollMs: number, session: RoomSessionToken, - sleepFn: (ms: number) => Promise + sleepFn: (ms: number) => Promise, + isCallerActive: () => boolean = () => true ) { while (true) { throwIfSessionCancelled(session); + throwIfDispatchCallerAbandoned(isCallerActive); if (remainingDispatchTime(getDeadline()) <= 0) { return null; } @@ -660,8 +718,10 @@ async function waitForReusableAgentParticipant( }); if (participant) { throwIfSessionCancelled(session); + throwIfDispatchCallerAbandoned(isCallerActive); return participant; } + throwIfDispatchCallerAbandoned(isCallerActive); if (remainingDispatchTime(getDeadline()) <= 0) { return null; } @@ -673,6 +733,41 @@ async function waitForReusableAgentParticipant( } } +function isDispatchCallerActive(inFlight: InFlightDispatch, caller: DispatchCaller) { + return inFlight.callers.has(caller) && caller.abortSignal?.aborted !== true; +} + +function hasActiveDispatchCaller(callers: Set) { + for (const caller of callers) { + if (caller.abortSignal?.aborted !== true) { + return true; + } + } + return false; +} + +function throwIfDispatchCallerAbandoned(isCallerActive: () => boolean): void { + if (!isCallerActive()) { + throw new DispatchCallerAbandonedError(); + } +} + +async function cleanupAbandonedDispatch( + inFlight: InFlightDispatch, + dispatch: Record, + roomName: string +) { + if (inFlight.accepted || hasActiveDispatchCaller(inFlight.callers)) { + return; + } + const dispatchId = typeof dispatch.dispatchId === 'string' ? dispatch.dispatchId : ''; + if (!dispatchId) { + return; + } + inFlight.cleanup ??= deleteDispatchQuietly(inFlight.dispatchClient, dispatchId, roomName); + await inFlight.cleanup; +} + function throwIfDeadlineExpired(deadline: number, phase: string): void { if (remainingDispatchTime(deadline) <= 0) { throw new Error(`${phase} deadline expired`); diff --git a/tests/session-prewarm.test.mjs b/tests/session-prewarm.test.mjs index 82469e691..f78cbd026 100644 --- a/tests/session-prewarm.test.mjs +++ b/tests/session-prewarm.test.mjs @@ -777,6 +777,118 @@ test('dispatch stops after a pre-deadline readiness query returns not-ready afte } }); +test('shared dispatch accepts a readiness query started before the deadline when it returns ready after it', async () => { + const originalNow = Date.now; + let now = 1_000; + const deadline = 2_000; + const agentName = 'frontdesk-browser-agent-shared-late-ready'; + let participantReads = 0; + let dispatchCreates = 0; + let deleteDispatchCalls = 0; + Date.now = () => now; + + try { + const result = await dispatchRoomSession( + { + roomName: 'voice_assistant_room_shared_late_ready', + sessionId: 'shared-late-ready', + agentName, + }, + { + dispatchClient: { + async createDispatch() { + dispatchCreates += 1; + now = deadline - 1; + return { id: 'dispatch-shared-late-ready' }; + }, + async deleteDispatch() { + deleteDispatchCalls += 1; + }, + }, + roomClient: { + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + + assert.equal(now, deadline - 1); + now = deadline + 1; + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }, + dispatchDeadlineMs: deadline, + dispatchPollMs: 100, + sleep: async () => { + assert.fail('a ready shared query should not sleep'); + }, + } + ); + + assert.equal(result.dispatchId, 'dispatch-shared-late-ready'); + assert.equal(result.agentParticipant.identity, 'agent-ready'); + assert.equal(participantReads, 2); + assert.equal(dispatchCreates, 1); + assert.equal(deleteDispatchCalls, 0); + assert.equal( + getRoomSessionSnapshot('voice_assistant_room_shared_late_ready')?.state, + 'running' + ); + } finally { + Date.now = originalNow; + } +}); + +test('dispatch reuses an existing participant returned after the deadline', async () => { + const originalNow = Date.now; + let now = 1_000; + const deadline = 2_000; + const agentName = 'frontdesk-browser-agent-existing-late-ready'; + let participantReads = 0; + Date.now = () => now; + + try { + const result = await dispatchRoomSession( + { + roomName: 'voice_assistant_room_existing_late_ready', + sessionId: 'existing-late-ready', + agentName, + }, + { + dispatchClient: { + async createDispatch() { + assert.fail('an existing participant should be reused'); + }, + async deleteDispatch() { + assert.fail('an existing participant has no dispatch to delete'); + }, + }, + roomClient: { + async listParticipants() { + participantReads += 1; + assert.equal(now, 1_000); + now = deadline + 1; + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }, + dispatchDeadlineMs: deadline, + sleep: async () => { + assert.fail('an existing ready participant should not sleep'); + }, + } + ); + + assert.equal(result.alreadyJoined, true); + assert.equal(result.attempts, 0); + assert.equal(result.agentParticipant.identity, 'agent-ready'); + assert.equal(participantReads, 1); + } finally { + Date.now = originalNow; + } +}); + test('prewarm timeout cleans up a dispatch whose in-flight readiness query returns late', async () => { const roomName = 'voice_assistant_room_prewarm_late_readiness'; const sessionId = 'prewarm-late-readiness'; @@ -852,6 +964,171 @@ test('prewarm timeout cleans up a dispatch whose in-flight readiness query retur assert.equal(getRoomSessionSnapshot(roomName)?.state, 'starting'); }); +test('prewarm timeout during caller readiness cleans up the shared dispatch', async () => { + const roomName = 'voice_assistant_room_prewarm_caller_readiness_timeout'; + const sessionId = 'prewarm-caller-readiness-timeout'; + const agentName = 'frontdesk-browser-agent-prewarm-caller-readiness-timeout'; + let participantReads = 0; + let deleteDispatchCalls = 0; + let markLateReadStarted; + let releaseLateRead; + const lateReadStarted = new Promise((resolve) => { + markLateReadStarted = resolve; + }); + const lateReadGate = new Promise((resolve) => { + releaseLateRead = resolve; + }); + + const pending = prewarmRoomSession( + { roomName, sessionId, agentName }, + { + dispatchClient: { + async createDispatch() { + return { id: 'dispatch-prewarm-caller-readiness-timeout' }; + }, + async deleteDispatch() { + deleteDispatchCalls += 1; + }, + }, + roomClient: { + async listRooms() { + return [{ name: roomName }]; + }, + async createRoom() { + assert.fail('the existing room should be reused'); + }, + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + if (participantReads === 2) { + return readyParticipants(agentName, { agentSessionReady: false }); + } + + markLateReadStarted(); + await lateReadGate; + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }, + waitForAgentWorkerReady: async () => ({ + state: 'ready', + agentName, + workerId: 'AW_prewarm_caller_readiness_timeout', + registeredAt: '2026-09-01T00:00:00Z', + waitedMs: 0, + }), + dispatchTimeoutMs: 100, + dispatchPollMs: 1, + } + ); + + await lateReadStarted; + let failure; + await assert.rejects(pending, (error) => { + failure = error; + assert.equal(error instanceof PrewarmRoomSessionError, true); + assert.match(error.message, /prewarm deadline expired during dispatch_readiness/); + return true; + }); + + assert.equal(deleteDispatchCalls, 0); + assert.ok(failure.retryReady); + releaseLateRead(); + await failure.retryReady; + + assert.equal(participantReads, 3); + assert.equal(deleteDispatchCalls, 1); + assert.equal(getRoomSessionSnapshot(roomName)?.state, 'starting'); +}); + +test('abandoned prewarm does not cancel a shared dispatch with an active regular caller', async () => { + const roomName = 'voice_assistant_room_shared_prewarm_abandonment'; + const sessionId = 'shared-prewarm-abandonment'; + const agentName = 'frontdesk-browser-agent-shared-prewarm-abandonment'; + let participantReads = 0; + let dispatchCreates = 0; + let deleteDispatchCalls = 0; + let markLateReadStarted; + let releaseLateRead; + const lateReadStarted = new Promise((resolve) => { + markLateReadStarted = resolve; + }); + const lateReadGate = new Promise((resolve) => { + releaseLateRead = resolve; + }); + const dispatchClient = { + async createDispatch() { + dispatchCreates += 1; + return { id: 'dispatch-shared-prewarm-abandonment' }; + }, + async deleteDispatch() { + deleteDispatchCalls += 1; + }, + }; + const roomClient = { + async listRooms() { + return [{ name: roomName }]; + }, + async createRoom() { + assert.fail('the existing room should be reused'); + }, + async listParticipants() { + participantReads += 1; + if (participantReads === 1) { + return []; + } + if (participantReads === 2) { + markLateReadStarted(); + await lateReadGate; + } + return readyParticipants(agentName); + }, + async deleteRoom() {}, + }; + const request = { roomName, sessionId, agentName }; + + const prewarm = prewarmRoomSession(request, { + dispatchClient, + roomClient, + waitForAgentWorkerReady: async () => ({ + state: 'ready', + agentName, + workerId: 'AW_shared_prewarm_abandonment', + registeredAt: '2026-09-01T00:00:00Z', + waitedMs: 0, + }), + dispatchTimeoutMs: 100, + dispatchPollMs: 1, + }); + + await lateReadStarted; + const regularDispatch = dispatchRoomSession(request, { + dispatchClient, + roomClient, + dispatchTimeoutMs: 1_000, + dispatchPollMs: 1, + }); + let prewarmFailure; + await assert.rejects(prewarm, (error) => { + prewarmFailure = error; + assert.equal(error instanceof PrewarmRoomSessionError, true); + assert.match(error.message, /prewarm deadline expired during dispatch_readiness/); + return true; + }); + + assert.equal(deleteDispatchCalls, 0); + releaseLateRead(); + const regularResult = await regularDispatch; + await prewarmFailure.retryReady; + + assert.equal(regularResult.dispatchId, 'dispatch-shared-prewarm-abandonment'); + assert.equal(dispatchCreates, 1); + assert.equal(deleteDispatchCalls, 0); + assert.equal(getRoomSessionSnapshot(roomName)?.state, 'running'); +}); + test('room timeout cannot create a room after a delayed list operation finishes', async () => { const originalNow = Date.now; Date.now = () => 1_000; @@ -1511,7 +1788,7 @@ test('shared dispatch token stays active through per-caller readiness waits', as assert.match( dispatchSource, - /inFlight\.callers === 0[\s\S]*finishRoomSessionDispatch\(inFlight\.session\)/ + /inFlight\.callers\.size === 0[\s\S]*finishRoomSessionDispatch\(inFlight\.session\)/ ); assert.doesNotMatch(readinessSource, /beginRoomSessionDispatch|finishRoomSessionDispatch/); });