fix(discord): unify reconnect watchdog and land #31025/#30530

Landed follow-up intent from contributor PR #31025 (@theotarr) and PR #30530 (@liuxiaopai-ai).

Co-authored-by: theotarr <theotarr@users.noreply.github.com>
Co-authored-by: liuxiaopai-ai <liuxiaopai-ai@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-02 00:23:07 +00:00
parent 0eac494db7
commit 0c0f556927
12 changed files with 462 additions and 5 deletions

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { createArmableStallWatchdog } from "./stall-watchdog.js";
describe("createArmableStallWatchdog", () => {
it("fires onTimeout once when armed and idle exceeds timeout", async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
const watchdog = createArmableStallWatchdog({
label: "test-watchdog",
timeoutMs: 1_000,
checkIntervalMs: 100,
onTimeout,
});
watchdog.arm();
await vi.advanceTimersByTimeAsync(1_500);
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(watchdog.isArmed()).toBe(false);
watchdog.stop();
} finally {
vi.useRealTimers();
}
});
it("does not fire when disarmed before timeout", async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
const watchdog = createArmableStallWatchdog({
label: "test-watchdog",
timeoutMs: 1_000,
checkIntervalMs: 100,
onTimeout,
});
watchdog.arm();
await vi.advanceTimersByTimeAsync(500);
watchdog.disarm();
await vi.advanceTimersByTimeAsync(2_000);
expect(onTimeout).not.toHaveBeenCalled();
watchdog.stop();
} finally {
vi.useRealTimers();
}
});
it("extends timeout window when touched", async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
const watchdog = createArmableStallWatchdog({
label: "test-watchdog",
timeoutMs: 1_000,
checkIntervalMs: 100,
onTimeout,
});
watchdog.arm();
await vi.advanceTimersByTimeAsync(700);
watchdog.touch();
await vi.advanceTimersByTimeAsync(700);
expect(onTimeout).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(400);
expect(onTimeout).toHaveBeenCalledTimes(1);
watchdog.stop();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,103 @@
import type { RuntimeEnv } from "../../runtime.js";
export type StallWatchdogTimeoutMeta = {
idleMs: number;
timeoutMs: number;
};
export type ArmableStallWatchdog = {
arm: (atMs?: number) => void;
touch: (atMs?: number) => void;
disarm: () => void;
stop: () => void;
isArmed: () => boolean;
};
export function createArmableStallWatchdog(params: {
label: string;
timeoutMs: number;
checkIntervalMs?: number;
abortSignal?: AbortSignal;
runtime?: RuntimeEnv;
onTimeout: (meta: StallWatchdogTimeoutMeta) => void;
}): ArmableStallWatchdog {
const timeoutMs = Math.max(1, Math.floor(params.timeoutMs));
const checkIntervalMs = Math.max(
100,
Math.floor(params.checkIntervalMs ?? Math.min(5_000, Math.max(250, timeoutMs / 6))),
);
let armed = false;
let stopped = false;
let lastActivityAt = Date.now();
let timer: ReturnType<typeof setInterval> | null = null;
const clearTimer = () => {
if (!timer) {
return;
}
clearInterval(timer);
timer = null;
};
const disarm = () => {
armed = false;
};
const stop = () => {
if (stopped) {
return;
}
stopped = true;
disarm();
clearTimer();
params.abortSignal?.removeEventListener("abort", stop);
};
const arm = (atMs?: number) => {
if (stopped) {
return;
}
lastActivityAt = atMs ?? Date.now();
armed = true;
};
const touch = (atMs?: number) => {
if (stopped) {
return;
}
lastActivityAt = atMs ?? Date.now();
};
const check = () => {
if (!armed || stopped) {
return;
}
const now = Date.now();
const idleMs = now - lastActivityAt;
if (idleMs < timeoutMs) {
return;
}
disarm();
params.runtime?.error?.(
`[${params.label}] transport watchdog timeout: idle ${Math.round(idleMs / 1000)}s (limit ${Math.round(timeoutMs / 1000)}s)`,
);
params.onTimeout({ idleMs, timeoutMs });
};
if (params.abortSignal?.aborted) {
stop();
} else {
params.abortSignal?.addEventListener("abort", stop, { once: true });
timer = setInterval(check, checkIntervalMs);
timer.unref?.();
}
return {
arm,
touch,
disarm,
stop,
isArmed: () => armed,
};
}