fix(signal): bound api mode cache clocks

This commit is contained in:
Peter Steinberger
2026-05-30 10:50:44 -04:00
parent 99e8cf22a8
commit 2d4369d176
2 changed files with 71 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
signalRpcRequest as signalRpcRequestImpl,
detectSignalApiMode,
@@ -37,6 +37,11 @@ beforeEach(() => {
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
function setApiMode(mode: SignalApiMode) {
currentApiMode = mode;
}
@@ -378,6 +383,44 @@ describe("signalCheck", () => {
error: "Signal API not reachable at http://localhost:8080",
});
});
it("drops cached auto mode when the current clock is not a valid date timestamp", async () => {
setApiMode("auto");
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
ok: true,
status: 200,
});
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
ok: true,
status: 200,
});
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
});
it("does not cache auto mode when the expiry timestamp would exceed the valid date range", async () => {
setApiMode("auto");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
ok: true,
status: 200,
});
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
ok: true,
status: 200,
});
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
});
});
describe("streamSignalEvents", () => {

View File

@@ -6,6 +6,10 @@
* only need to change their import path.
*/
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
containerCheck,
containerRpcRequest,
@@ -74,24 +78,33 @@ async function resolveAutoApiMode(
timeoutMs = DEFAULT_TIMEOUT_MS,
options: { account?: string; requireContainerReceive?: boolean } = {},
): Promise<"native" | "container"> {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = detectedModeCache.get(baseUrl);
if (cached && cached.expiresAt > Date.now()) {
if (
cached.mode !== "container" ||
!options.requireContainerReceive ||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
) {
return cached.mode;
if (cached) {
if (now !== undefined && cached.expiresAt > now) {
if (
cached.mode !== "container" ||
!options.requireContainerReceive ||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
) {
return cached.mode;
}
} else {
detectedModeCache.delete(baseUrl);
}
}
const detected = await detectSignalApiMode(baseUrl, timeoutMs, options);
detectedModeCache.set(baseUrl, {
mode: detected,
expiresAt: Date.now() + MODE_CACHE_TTL_MS,
...(detected === "container" && options.requireContainerReceive && options.account
? { receiveAccount: options.account }
: {}),
});
const expiresAt = resolveExpiresAtMsFromDurationMs(MODE_CACHE_TTL_MS, { nowMs: rawNow });
if (expiresAt !== undefined) {
detectedModeCache.set(baseUrl, {
mode: detected,
expiresAt,
...(detected === "container" && options.requireContainerReceive && options.account
? { receiveAccount: options.account }
: {}),
});
}
return detected;
}