diff --git a/extensions/signal/src/client-adapter.test.ts b/extensions/signal/src/client-adapter.test.ts index 30e7bad65b4..5028b89a952 100644 --- a/extensions/signal/src/client-adapter.test.ts +++ b/extensions/signal/src/client-adapter.test.ts @@ -159,6 +159,16 @@ describe("detectSignalApiMode", () => { expect(result).toBe("native"); }); + it("prefers native even when the container probe resolves first", async () => { + mockNativeCheck.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true, status: 200 }), 1)), + ); + mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); + + const result = await detectSignalApiMode("http://localhost:8080"); + expect(result).toBe("native"); + }); + it("throws error when neither endpoint responds", async () => { mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); @@ -530,6 +540,26 @@ describe("streamSignalEvents", () => { "+14259798283", ); }); + + it("does not reuse a cached container mode for no-account receive streams", async () => { + setApiMode("auto"); + mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); + mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); + + await expect(signalCheck("http://auto-cache-no-account.local:8080")).resolves.toEqual({ + ok: true, + status: 200, + }); + + await expect( + streamSignalEvents({ + baseUrl: "http://auto-cache-no-account.local:8080", + onEvent: vi.fn(), + }), + ).rejects.toThrow("Signal API not reachable at http://auto-cache-no-account.local:8080"); + expect(mockStreamContainerEvents).not.toHaveBeenCalled(); + expect(mockContainerCheck).toHaveBeenCalledTimes(2); + }); }); describe("fetchAttachment", () => { diff --git a/extensions/signal/src/client-adapter.ts b/extensions/signal/src/client-adapter.ts index 56721c163de..c0410036a11 100644 --- a/extensions/signal/src/client-adapter.ts +++ b/extensions/signal/src/client-adapter.ts @@ -65,7 +65,7 @@ async function resolveAutoApiMode( if ( cached.mode !== "container" || !options.requireContainerReceive || - cached.receiveAccount === options.account + (Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim()) ) { return cached.mode; } @@ -103,32 +103,29 @@ async function resolveApiModeForOperation(params: { /** * Detect which Signal API mode is available by probing endpoints. - * First endpoint to respond OK wins. + * Native wins when both APIs are healthy because it preserves the richer JSON-RPC contract. */ export async function detectSignalApiMode( baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS, options: { account?: string; requireContainerReceive?: boolean } = {}, ): Promise<"native" | "container"> { - const nativePromise = nativeCheck(baseUrl, timeoutMs).then((r) => - r.ok ? ("native" as const) : Promise.reject(new Error("native not ok")), - ); const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined; + const nativePromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); const containerPromise = containerAccount - ? containerCheck(baseUrl, timeoutMs, containerAccount).then((r) => - r.ok ? ("container" as const) : Promise.reject(new Error("container not ok")), - ) + ? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false })) : options.requireContainerReceive - ? Promise.reject(new Error("container receive account required")) - : containerCheck(baseUrl, timeoutMs).then((r) => - r.ok ? ("container" as const) : Promise.reject(new Error("container not ok")), - ); + ? Promise.resolve({ ok: false }) + : containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); - try { - return await Promise.any([nativePromise, containerPromise]); - } catch { - throw new Error(`Signal API not reachable at ${baseUrl}`); + const [nativeResult, containerResult] = await Promise.all([nativePromise, containerPromise]); + if (nativeResult.ok) { + return "native"; } + if (containerResult.ok) { + return "container"; + } + throw new Error(`Signal API not reachable at ${baseUrl}`); } /** diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index 9a9dbc04bbe..1d907234385 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -186,6 +186,21 @@ describe("TwitchClientManager", () => { expect(mockConnect).toHaveBeenCalledTimes(1); }); + it("deduplicates concurrent client creation for the same account", async () => { + mockConnect.mockImplementationOnce(() => {}); + + const first = manager.getClient(testAccount); + const second = manager.getClient(testAccount); + await Promise.resolve(); + + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(authSuccessHandlers).toHaveLength(1); + authSuccessHandlers[0]?.(); + + const [client1, client2] = await Promise.all([first, second]); + expect(client1).toBe(client2); + }); + it("should create separate clients for different accounts", async () => { await manager.getClient(testAccount); await manager.getClient(testAccount2);