mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 03:30:44 +00:00
fix(feishu): cap per-chat queue task wait so a single hang doesn't starve later messages
Per-chat sequential queue had no timeout: if a single dispatch hung (e.g. an agent call that never resolved), every subsequent message in the same chat stayed `queued` until the gateway was restarted. Add an optional `taskTimeoutMs` (default 5 min) to `createSequentialQueue`. After the cap, the in-flight task is evicted from the blocking chain so newer same-key tasks can proceed. The original task is NOT aborted — it continues running in the background; we just stop starving the queue. A warning log surfaces the eviction with the offending key. `taskTimeoutMs: 0` restores legacy unbounded behavior. Same-chat FIFO ordering for normal-cadence messages is preserved (see #64324) — only pathologically slow tasks get evicted. Fixes #70133.
This commit is contained in:
committed by
Peter Steinberger
parent
0bf06e953f
commit
0028e6040a
@@ -89,4 +89,67 @@ describe("createSequentialQueue", () => {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts a stuck task after taskTimeoutMs so newer same-key work proceeds", async () => {
|
||||
const timeouts: Array<{ key: string; timeoutMs: number }> = [];
|
||||
const enqueue = createSequentialQueue({
|
||||
taskTimeoutMs: 25,
|
||||
onTaskTimeout: (key, timeoutMs) => {
|
||||
timeouts.push({ key, timeoutMs });
|
||||
},
|
||||
});
|
||||
const order: string[] = [];
|
||||
|
||||
// Stuck task — never resolves until the test cleans up.
|
||||
const stuckGate = createDeferred();
|
||||
const stuck = enqueue("feishu:default:chat-stuck", async () => {
|
||||
order.push("stuck:start");
|
||||
await stuckGate.promise;
|
||||
order.push("stuck:end");
|
||||
});
|
||||
|
||||
// Second same-key task — would be starved indefinitely without the cap.
|
||||
const followUp = enqueue("feishu:default:chat-stuck", async () => {
|
||||
order.push("follow-up:ran");
|
||||
});
|
||||
|
||||
await followUp;
|
||||
|
||||
expect(order).toEqual(["stuck:start", "follow-up:ran"]);
|
||||
expect(timeouts).toEqual([{ key: "feishu:default:chat-stuck", timeoutMs: 25 }]);
|
||||
|
||||
// Drain the leaked stuck task so it doesn't trip the unhandled-rejection guard.
|
||||
stuckGate.resolve();
|
||||
await stuck;
|
||||
});
|
||||
|
||||
it("disables the timeout cap when taskTimeoutMs is 0 (legacy behavior)", async () => {
|
||||
const timeouts: Array<{ key: string; timeoutMs: number }> = [];
|
||||
const enqueue = createSequentialQueue({
|
||||
taskTimeoutMs: 0,
|
||||
onTaskTimeout: (key, timeoutMs) => {
|
||||
timeouts.push({ key, timeoutMs });
|
||||
},
|
||||
});
|
||||
const gate = createDeferred();
|
||||
const order: string[] = [];
|
||||
|
||||
const first = enqueue("feishu:default:chat-1", async () => {
|
||||
order.push("first:start");
|
||||
await gate.promise;
|
||||
order.push("first:end");
|
||||
});
|
||||
const second = enqueue("feishu:default:chat-1", async () => {
|
||||
order.push("second:ran");
|
||||
});
|
||||
|
||||
// Wait long enough that a timeout would have fired if it were active.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(order).toEqual(["first:start"]);
|
||||
expect(timeouts).toEqual([]);
|
||||
|
||||
gate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second:ran"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user