fix: harden typing lifecycle and cross-channel suppression

This commit is contained in:
Peter Steinberger
2026-02-26 17:01:03 +01:00
parent 4894d907fa
commit 37a138c554
14 changed files with 359 additions and 85 deletions

View File

@@ -73,6 +73,80 @@ describe("createTypingCallbacks", () => {
}
});
it("stops keepalive after consecutive start failures", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockRejectedValue(new Error("gone"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_000);
expect(start).toHaveBeenCalledTimes(2);
expect(onStartError).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(9_000);
expect(start).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("does not restart keepalive when breaker trips on initial start", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockRejectedValue(new Error("gone"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
onStartError,
maxConsecutiveFailures: 1,
});
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(9_000);
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("resets failure counter after a successful keepalive tick", async () => {
vi.useFakeTimers();
try {
let callCount = 0;
const start = vi.fn().mockImplementation(async () => {
callCount += 1;
if (callCount % 2 === 1) {
throw new Error("flaky");
}
});
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
onStartError,
maxConsecutiveFailures: 2,
});
await callbacks.onReplyStart(); // fail
await vi.advanceTimersByTimeAsync(3_000); // success
await vi.advanceTimersByTimeAsync(3_000); // fail
await vi.advanceTimersByTimeAsync(3_000); // success
await vi.advanceTimersByTimeAsync(3_000); // fail
expect(start).toHaveBeenCalledTimes(5);
expect(onStartError).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
it("deduplicates stop across idle and cleanup", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);

View File

@@ -13,6 +13,8 @@ export type CreateTypingCallbacksParams = {
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
keepaliveIntervalMs?: number;
/** Stop keepalive after this many consecutive start() failures. Default: 2 */
maxConsecutiveFailures?: number;
/** Maximum duration for typing indicator before auto-cleanup (safety TTL). Default: 60s */
maxDurationMs?: number;
};
@@ -20,19 +22,31 @@ export type CreateTypingCallbacksParams = {
export function createTypingCallbacks(params: CreateTypingCallbacksParams): TypingCallbacks {
const stop = params.stop;
const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000;
const maxConsecutiveFailures = Math.max(1, params.maxConsecutiveFailures ?? 2);
const maxDurationMs = params.maxDurationMs ?? 60_000; // Default 60s TTL
let stopSent = false;
let closed = false;
let consecutiveFailures = 0;
let breakerTripped = false;
let ttlTimer: ReturnType<typeof setTimeout> | undefined;
const fireStart = async () => {
const fireStart = async (): Promise<void> => {
if (closed) {
return;
}
if (breakerTripped) {
return;
}
try {
await params.start();
consecutiveFailures = 0;
} catch (err) {
consecutiveFailures += 1;
params.onStartError(err);
if (consecutiveFailures >= maxConsecutiveFailures) {
breakerTripped = true;
keepaliveLoop.stop();
}
}
};
@@ -67,9 +81,14 @@ export function createTypingCallbacks(params: CreateTypingCallbacksParams): Typi
return;
}
stopSent = false;
breakerTripped = false;
consecutiveFailures = 0;
keepaliveLoop.stop();
clearTtlTimer();
await fireStart();
if (breakerTripped) {
return;
}
keepaliveLoop.start();
startTtlTimer(); // Start TTL safety timer
};