diff --git a/extensions/azure-speech/voices-timeout.test.ts b/extensions/azure-speech/voices-timeout.test.ts index de26152d9a18..4dce8edbe1c2 100644 --- a/extensions/azure-speech/voices-timeout.test.ts +++ b/extensions/azure-speech/voices-timeout.test.ts @@ -3,7 +3,6 @@ // the real fetch abort path without depending on Azure latency. import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; -import { clearTimeout as clearRealTimeout, setTimeout as setRealTimeout } from "node:timers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { listAzureSpeechVoices } from "./tts.js"; @@ -35,62 +34,56 @@ describe("listAzureSpeechVoices timeout", () => { vi.unstubAllGlobals(); }); - it( - "aborts a hanging voice list request within the configured timeout", - { timeout: 2_000 }, - async () => { - let requestCount = 0; - let notifyRequest = () => {}; - const requestReceived = new Promise((resolve) => { - notifyRequest = resolve; - }); - const server = createServer((_request, _response) => { - requestCount += 1; - notifyRequest(); - }); + it("aborts a hanging voice list request within the configured timeout", async () => { + let requestCount = 0; + let requestSignal: AbortSignal | undefined; + const cleanupController = new AbortController(); + let notifyRequest = () => {}; + const requestReceived = new Promise((resolve) => { + notifyRequest = resolve; + }); + const server = createServer((_request, _response) => { + requestCount += 1; + notifyRequest(); + }); - const port = await listenLocal(server); + const port = await listenLocal(server); - vi.stubGlobal( - "fetch", - vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - return await originalFetch( - `http://127.0.0.1:${port}/cognitiveservices/voices/list`, - init, - ); - }) as unknown as typeof globalThis.fetch, - ); - - const startedAt = Date.now(); - let watchdog: ReturnType | undefined; - - try { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const watchdogPromise = new Promise((_, reject) => { - watchdog = setRealTimeout(() => reject(new Error("voices list did not time out")), 1_000); - }); - const request = Promise.race([ - listAzureSpeechVoices({ - apiKey: "not-a-real", - baseUrl: "https://custom.example.com", - timeoutMs: 100, - }), - watchdogPromise, - ]); - const rejection = expect(request).rejects.toThrow(/aborted|timeout|timed out/i); - - await Promise.race([requestReceived, watchdogPromise]); - expect(requestCount).toBe(1); - await vi.advanceTimersByTimeAsync(100); - await rejection; - expect(Date.now() - startedAt).toBeLessThan(1_000); - } finally { - vi.useRealTimers(); - if (watchdog) { - clearRealTimeout(watchdog); + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + if (!requestSignal) { + throw new Error("guarded fetch did not pass an abort signal"); } - await closeServer(server); - } - }, - ); + return await originalFetch(`http://127.0.0.1:${port}/cognitiveservices/voices/list`, { + ...init, + signal: AbortSignal.any([requestSignal, cleanupController.signal]), + }); + }) as unknown as typeof globalThis.fetch, + ); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const request = listAzureSpeechVoices({ + apiKey: "not-a-real", + baseUrl: "https://custom.example.com", + timeoutMs: 100, + }); + try { + // Measure the request deadline after cold SDK imports and socket setup finish. + await Promise.race([requestReceived, request]); + expect(requestCount).toBe(1); + await vi.advanceTimersByTimeAsync(99); + expect(requestSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(requestSignal?.aborted).toBe(true); + await expect(request).rejects.toThrow(/aborted|timeout|timed out/i); + } finally { + // Abort independently of the production deadline, then settle before global/socket cleanup. + cleanupController.abort(); + await request.catch(() => undefined); + vi.useRealTimers(); + await closeServer(server); + } + }); }); diff --git a/extensions/inworld/voices-timeout.test.ts b/extensions/inworld/voices-timeout.test.ts index 516c8fe901d4..798c903f2fda 100644 --- a/extensions/inworld/voices-timeout.test.ts +++ b/extensions/inworld/voices-timeout.test.ts @@ -14,9 +14,15 @@ describe("listInworldVoices timeout", () => { it("aborts a hanging voice list request within the configured timeout", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let notifyFetchStarted!: () => void; + const fetchStarted = new Promise((resolve) => { + notifyFetchStarted = resolve; + }); + let rejectFetch: ((error: Error) => void) | undefined; const fetchMock = vi.fn( (_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + rejectFetch = reject; const signal = init?.signal; if (!signal) { reject(new Error("guarded fetch did not pass an abort signal")); @@ -28,6 +34,7 @@ describe("listInworldVoices timeout", () => { reject(signal.reason instanceof Error ? signal.reason : new Error("request aborted")), { once: true }, ); + notifyFetchStarted(); }), ); vi.stubGlobal("fetch", fetchMock as unknown as typeof globalThis.fetch); @@ -37,16 +44,21 @@ describe("listInworldVoices timeout", () => { baseUrl: "https://custom.inworld.example.com", timeoutMs: 250, }); - const rejection = expect(request).rejects.toThrow(/aborted|timeout|timed out/i); + try { + // Cold SDK imports must finish before the guarded fetch deadline starts. + await Promise.race([fetchStarted, request]); + expect(fetchMock).toHaveBeenCalledTimes(1); - // Flush guard preflight microtasks so the request is in flight. - await vi.advanceTimersByTimeAsync(0); - expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(249); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false); - await vi.advanceTimersByTimeAsync(249); - expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - await rejection; + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await expect(request).rejects.toThrow(/aborted|timeout|timed out/i); + } finally { + // Cleanup must not depend on the production timeout working. + rejectFetch?.(new Error("test cleanup")); + await request.catch(() => undefined); + } }); });