mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(qqbot): clean up token refresh sleep abort listener (#105886)
* fix(qqbot): clean up token refresh sleep abort listener * refactor(qqbot): reuse abort-safe sleep * test(qqbot): always restore listener spy * fix(qqbot): preserve refresh loop yield * chore(qqbot): retrigger CI after main fixes --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
c13aa612eb
commit
1d1c1211a5
@@ -1,4 +1,5 @@
|
||||
// Qqbot tests cover token plugin behavior.
|
||||
import { getEventListeners } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TokenManager } from "./token.js";
|
||||
|
||||
@@ -247,4 +248,52 @@ describe("QQBot token manager", () => {
|
||||
expect(stalledFetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("yields and does not grow abort listeners across zero-delay refresh sleeps", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
|
||||
const accessTokenField = ["access", "token"].join("_");
|
||||
for (let i = 1; i <= 4; i += 1) {
|
||||
const body = JSON.stringify({ [accessTokenField]: `token-${i}`, expires_in: 0 });
|
||||
mockGuardedTokenResponse(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const addListenerSpy = vi.spyOn(AbortSignal.prototype, "addEventListener");
|
||||
const activeAbortListenerCount = () =>
|
||||
[...new Set(addListenerSpy.mock.instances)]
|
||||
.filter((signal): signal is AbortSignal => signal instanceof AbortSignal)
|
||||
.reduce((count, signal) => count + getEventListeners(signal, "abort").length, 0);
|
||||
|
||||
const manager = new TokenManager();
|
||||
try {
|
||||
manager.startBackgroundRefresh("app-id", "secret", {
|
||||
refreshAheadMs: 0,
|
||||
randomOffsetMs: 0,
|
||||
minRefreshIntervalMs: 0,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
|
||||
expect(activeAbortListenerCount()).toBe(1);
|
||||
|
||||
for (let cycle = 2; cycle <= 4; cycle += 1) {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(cycle);
|
||||
expect(activeAbortListenerCount()).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
manager.stopBackgroundRefresh("app-id");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
try {
|
||||
expect(activeAbortListenerCount()).toBe(0);
|
||||
} finally {
|
||||
addListenerSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveTimestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
@@ -173,6 +174,10 @@ export class TokenManager {
|
||||
const controller = new AbortController();
|
||||
this.refreshControllers.set(appId, controller);
|
||||
const { signal } = controller;
|
||||
// Preserve the old timer's event-loop yield for zero/invalid overrides;
|
||||
// the shared helper's no-op semantics would let this refresh loop spin.
|
||||
const sleepAndYield = (ms: number) =>
|
||||
sleepWithAbort(Number.isFinite(ms) ? Math.max(ms, 1) : 1, signal);
|
||||
|
||||
const loop = async () => {
|
||||
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
|
||||
@@ -192,9 +197,9 @@ export class TokenManager {
|
||||
this.logger?.debug?.(
|
||||
`[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`,
|
||||
);
|
||||
await this.abortableSleep(refreshIn, signal);
|
||||
await sleepAndYield(refreshIn);
|
||||
} else {
|
||||
await this.abortableSleep(minRefreshIntervalMs, signal);
|
||||
await sleepAndYield(minRefreshIntervalMs);
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) {
|
||||
@@ -203,7 +208,7 @@ export class TokenManager {
|
||||
this.logger?.error?.(
|
||||
`[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
await this.abortableSleep(retryDelayMs, signal);
|
||||
await sleepAndYield(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,20 +319,4 @@ export class TokenManager {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user