mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 04:22:43 +00:00
fix: harden typing lifecycle and cross-channel suppression
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user