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:
Alix-007
2026-07-14 05:40:34 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent c13aa612eb
commit 1d1c1211a5
2 changed files with 57 additions and 19 deletions
@@ -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();
}
}
});
});
+8 -19
View File
@@ -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 });
});
}
}