From cf4d2659d882549df89a63769303d1883f152ee4 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Sun, 8 Mar 2026 06:57:37 -0400 Subject: [PATCH] fix(synology-chat,twitch,zalouser): clear timeout timers in Promise.race patterns Several extensions use Promise.race with setTimeout-based timeouts but never clear the timer when the main promise wins the race: - synology-chat webhook-handler: 120s agent response timeout rejects after nobody is listening, causing an unhandled promise rejection - twitch probe: connection timeout rejects after successful connect - zalouser probe: timeout timer keeps running after user info resolves Store timer handles and clear them in finally blocks so timers are always cleaned up regardless of which side wins the race. Co-Authored-By: Claude Opus 4.6 --- .../synology-chat/src/webhook-handler.ts | 23 +++++++++++-------- extensions/twitch/src/probe.ts | 9 ++++++-- extensions/zalouser/src/probe.ts | 22 ++++++++++++------ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index b4c73934db9..b4dd96876a3 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -369,17 +369,22 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { chatUserId: replyUserId, }); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000), - ); + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000); + }); - const reply = await Promise.race([deliverPromise, timeoutPromise]); + try { + const reply = await Promise.race([deliverPromise, timeoutPromise]); - // Send reply back to Synology Chat using the resolved Chat user_id - if (reply) { - await sendMessage(account.incomingUrl, reply, replyUserId, account.allowInsecureSsl); - const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply; - log?.info(`Reply sent to ${payload.username} (${replyUserId}): ${replyPreview}`); + // Send reply back to Synology Chat using the resolved Chat user_id + if (reply) { + await sendMessage(account.incomingUrl, reply, replyUserId, account.allowInsecureSsl); + const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply; + log?.info(`Reply sent to ${payload.username} (${replyUserId}): ${replyPreview}`); + } + } finally { + clearTimeout(timer); } } catch (err) { const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err); diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index 7ce02501007..9d0c93c0f62 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -82,12 +82,17 @@ export async function probeTwitch( }); }); + let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); + timer = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); }); client.connect(); - await Promise.race([connectionPromise, timeout]); + try { + await Promise.race([connectionPromise, timeout]); + } finally { + clearTimeout(timer); + } client.quit(); client = undefined; diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts index b3213010f26..0db956684fe 100644 --- a/extensions/zalouser/src/probe.ts +++ b/extensions/zalouser/src/probe.ts @@ -11,14 +11,22 @@ export async function probeZalouser( timeoutMs?: number, ): Promise { try { - const user = timeoutMs - ? await Promise.race([ + let user: ZcaUserInfo | null; + if (timeoutMs) { + let timer: ReturnType | undefined; + try { + user = await Promise.race([ getZaloUserInfo(profile), - new Promise((resolve) => - setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)), - ), - ]) - : await getZaloUserInfo(profile); + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)); + }), + ]); + } finally { + clearTimeout(timer); + } + } else { + user = await getZaloUserInfo(profile); + } if (!user) { return { ok: false, error: "Not authenticated" };