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 <noreply@anthropic.com>
This commit is contained in:
Andrew Barnes 2026-03-08 06:57:37 -04:00
parent aedf3ee68f
commit cf4d2659d8
No known key found for this signature in database
GPG Key ID: A2B96F4BB60D03A1
3 changed files with 36 additions and 18 deletions

View File

@ -369,17 +369,22 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
chatUserId: replyUserId,
});
const timeoutPromise = new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000),
);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<null>((_, 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);

View File

@ -82,12 +82,17 @@ export async function probeTwitch(
});
});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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;

View File

@ -11,14 +11,22 @@ export async function probeZalouser(
timeoutMs?: number,
): Promise<ZalouserProbeResult> {
try {
const user = timeoutMs
? await Promise.race([
let user: ZcaUserInfo | null;
if (timeoutMs) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
user = await Promise.race([
getZaloUserInfo(profile),
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
),
])
: await getZaloUserInfo(profile);
new Promise<null>((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" };