From 5b2703e24dd520bb6cc703e9dfa638c76286f548 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 24 May 2026 01:28:53 +0100 Subject: [PATCH] fix(plugins): avoid Signal and Twitch setup regressions --- extensions/signal/src/client-adapter.test.ts | 15 +++++++ extensions/signal/src/client-adapter.ts | 47 ++++++++++++++++---- extensions/twitch/src/twitch-client.test.ts | 26 +++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/extensions/signal/src/client-adapter.test.ts b/extensions/signal/src/client-adapter.test.ts index 5028b89a952..4701bdf8c77 100644 --- a/extensions/signal/src/client-adapter.test.ts +++ b/extensions/signal/src/client-adapter.test.ts @@ -169,6 +169,21 @@ describe("detectSignalApiMode", () => { expect(result).toBe("native"); }); + it("returns container after the native preference grace when native does not respond", async () => { + vi.useFakeTimers(); + try { + mockNativeCheck.mockImplementation(() => new Promise(() => {})); + mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); + + const result = detectSignalApiMode("http://localhost:8080"); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(50); + await expect(result).resolves.toBe("container"); + } finally { + vi.useRealTimers(); + } + }); + 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" }); diff --git a/extensions/signal/src/client-adapter.ts b/extensions/signal/src/client-adapter.ts index c0410036a11..afa9a5ef3f6 100644 --- a/extensions/signal/src/client-adapter.ts +++ b/extensions/signal/src/client-adapter.ts @@ -21,6 +21,7 @@ import { const DEFAULT_TIMEOUT_MS = 10_000; const MODE_CACHE_TTL_MS = 30_000; +const NATIVE_PREFERENCE_GRACE_MS = 50; export type SignalSseEvent = { event?: string; @@ -55,6 +56,19 @@ function resolveAutoProbeTimeoutMs(timeoutMs: number | undefined): number { : DEFAULT_TIMEOUT_MS; } +function waitForNativePreferenceGrace( + nativeResultPromise: Promise<{ ok: boolean }>, +): Promise<{ ok: boolean }> { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve({ ok: false }), NATIVE_PREFERENCE_GRACE_MS); + timer.unref?.(); + nativeResultPromise.then((result) => { + clearTimeout(timer); + resolve(result); + }); + }); +} + async function resolveAutoApiMode( baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS, @@ -111,21 +125,36 @@ export async function detectSignalApiMode( options: { account?: string; requireContainerReceive?: boolean } = {}, ): Promise<"native" | "container"> { const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined; - const nativePromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); - const containerPromise = containerAccount + const nativeResultPromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); + const containerResultPromise = containerAccount ? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false })) : options.requireContainerReceive ? Promise.resolve({ ok: false }) : containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); - const [nativeResult, containerResult] = await Promise.all([nativePromise, containerPromise]); - if (nativeResult.ok) { - return "native"; + const nativeHealthyPromise = nativeResultPromise.then((result) => { + if (result.ok) { + return "native" as const; + } + throw new Error("native not ok"); + }); + const containerHealthyPromise = containerResultPromise.then((result) => { + if (result.ok) { + return "container" as const; + } + throw new Error("container not ok"); + }); + + try { + const firstHealthy = await Promise.any([nativeHealthyPromise, containerHealthyPromise]); + if (firstHealthy === "native") { + return "native"; + } + const nativeResult = await waitForNativePreferenceGrace(nativeResultPromise); + return nativeResult.ok ? "native" : "container"; + } catch { + throw new Error(`Signal API not reachable at ${baseUrl}`); } - 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 1d907234385..2d7e2ad2f6a 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -201,6 +201,32 @@ describe("TwitchClientManager", () => { expect(client1).toBe(client2); }); + it("waits for disconnect after authentication failure retry events", async () => { + mockConnect.mockImplementationOnce(() => {}); + + const connection = manager.getClient(testAccount); + await Promise.resolve(); + authFailureHandlers[0]?.("bad token", 1); + + let settled = false; + void connection.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Twitch authentication failed for testbot; waiting for retry, disconnect, or timeout: bad token", + ); + + disconnectHandlers[0]?.(false, new Error("disconnected")); + await expect(connection).rejects.toThrow("disconnected"); + }); + it("should create separate clients for different accounts", async () => { await manager.getClient(testAccount); await manager.getClient(testAccount2);