mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 04:21:41 +00:00
fix(ui): bound stale-chunk recovery probes (#105795)
This commit is contained in:
@@ -50,6 +50,7 @@ afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
resetStaleChunkReloadStateForTest();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("openclaw-router-outlet", () => {
|
||||
@@ -122,8 +123,20 @@ describe("openclaw-router-outlet", () => {
|
||||
});
|
||||
|
||||
it("schedules stale-chunk recovery and falls back to revalidation while offline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let loadCount = 0;
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 503 }));
|
||||
const fetchMock = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => reject(new Error("document probe aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
|
||||
routes: [
|
||||
@@ -149,13 +162,17 @@ describe("openclaw-router-outlet", () => {
|
||||
const alert = outlet.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain("Importing a module script failed.");
|
||||
expect(alert?.textContent).toContain("Reload the page");
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadCount).toBe(1);
|
||||
|
||||
outlet.querySelector<HTMLButtonElement>("button")?.click();
|
||||
await vi.waitFor(() => expect(loadCount).toBe(2));
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
vi.runAllTicks();
|
||||
await settleOutlet(outlet);
|
||||
expect(loadCount).toBe(2);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
outlet.remove();
|
||||
router.stop();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,51 @@ import {
|
||||
} from "./stale-chunk-reload.ts";
|
||||
|
||||
const GUARD_KEY = "openclaw.controlUi.staleChunkReloadBuildId";
|
||||
const PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve: (value: T) => void = () => {
|
||||
throw new Error("deferred promise was not initialized");
|
||||
};
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function stubDocumentFetch(...responses: Response[]) {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => {
|
||||
const response = responses.shift();
|
||||
if (!response) {
|
||||
throw new Error("unexpected document probe");
|
||||
}
|
||||
return response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function stubHangingDocumentFetch() {
|
||||
const fetchMock = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => reject(new Error("document probe aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial));
|
||||
@@ -19,6 +64,8 @@ function memoryStorage(initial: Record<string, string> = {}) {
|
||||
|
||||
afterEach(() => {
|
||||
resetStaleChunkReloadStateForTest();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("isStaleChunkImportError", () => {
|
||||
@@ -42,12 +89,12 @@ describe("scheduleStaleChunkReload", () => {
|
||||
it("reloads once the document probe succeeds and records the build guard", async () => {
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage();
|
||||
stubDocumentFetch(new Response(null, { status: 200 }));
|
||||
await expect(
|
||||
scheduleStaleChunkReload({
|
||||
now: () => 1000,
|
||||
buildId: "build-a",
|
||||
storage,
|
||||
probeDocument: async () => true,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
@@ -58,12 +105,12 @@ describe("scheduleStaleChunkReload", () => {
|
||||
it("never auto-reloads twice for the same build, but recovers on a newer build", async () => {
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage({ [GUARD_KEY]: "build-a" });
|
||||
stubDocumentFetch(new Response(null, { status: 200 }));
|
||||
await expect(
|
||||
scheduleStaleChunkReload({
|
||||
now: () => 1000,
|
||||
buildId: "build-a",
|
||||
storage,
|
||||
probeDocument: async () => true,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
@@ -74,7 +121,6 @@ describe("scheduleStaleChunkReload", () => {
|
||||
now: () => 2000,
|
||||
buildId: "build-b",
|
||||
storage,
|
||||
probeDocument: async () => true,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
@@ -85,11 +131,11 @@ describe("scheduleStaleChunkReload", () => {
|
||||
it("does not reload or set the guard while the gateway is unreachable", async () => {
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage();
|
||||
stubDocumentFetch(new Response(null, { status: 503 }));
|
||||
await expect(
|
||||
scheduleStaleChunkReload({
|
||||
now: () => 1000,
|
||||
storage,
|
||||
probeDocument: async () => false,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
@@ -99,11 +145,11 @@ describe("scheduleStaleChunkReload", () => {
|
||||
|
||||
it("does not auto-reload when the guard cannot be persisted", async () => {
|
||||
const reload = vi.fn();
|
||||
stubDocumentFetch(new Response(null, { status: 200 }));
|
||||
await expect(
|
||||
scheduleStaleChunkReload({
|
||||
now: () => 1000,
|
||||
storage: null,
|
||||
probeDocument: async () => true,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
@@ -117,7 +163,6 @@ describe("scheduleStaleChunkReload", () => {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
},
|
||||
probeDocument: async () => true,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
@@ -126,23 +171,63 @@ describe("scheduleStaleChunkReload", () => {
|
||||
|
||||
it("applies an in-memory cooldown between attempts", async () => {
|
||||
const reload = vi.fn();
|
||||
const probeDocument = vi.fn(async () => true);
|
||||
const storage = memoryStorage();
|
||||
const fetchMock = stubDocumentFetch(
|
||||
new Response(null, { status: 503 }),
|
||||
new Response(null, { status: 200 }),
|
||||
);
|
||||
await expect(
|
||||
scheduleStaleChunkReload({
|
||||
now: () => 1000,
|
||||
storage,
|
||||
probeDocument: async () => false,
|
||||
reload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
scheduleStaleChunkReload({ now: () => 2000, storage, probeDocument, reload }),
|
||||
).resolves.toBe(false);
|
||||
expect(probeDocument).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
scheduleStaleChunkReload({ now: () => 7000, storage, probeDocument, reload }),
|
||||
).resolves.toBe(true);
|
||||
await expect(scheduleStaleChunkReload({ now: () => 2000, storage, reload })).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await expect(scheduleStaleChunkReload({ now: () => 7000, storage, reload })).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("settles and aborts a hanging document probe after its deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
const fetchMock = stubHangingDocumentFetch();
|
||||
const retry = retryStaleChunkReload({ reload });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
const result = expect(retry).resolves.toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(PROBE_TIMEOUT_MS);
|
||||
await result;
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces automatic and manual probes and clears the busy state", async () => {
|
||||
const firstProbe = deferred<Response>();
|
||||
const fetchMock = vi.fn<typeof fetch>().mockImplementationOnce(async () => firstProbe.promise);
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage();
|
||||
|
||||
const automatic = scheduleStaleChunkReload({ now: () => 1000, storage, reload });
|
||||
const manual = retryStaleChunkReload({ reload });
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstProbe.resolve(new Response(null, { status: 503 }));
|
||||
await expect(Promise.all([automatic, manual])).resolves.toEqual([false, false]);
|
||||
await expect(retryStaleChunkReload({ reload })).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -150,17 +235,15 @@ describe("scheduleStaleChunkReload", () => {
|
||||
describe("retryStaleChunkReload", () => {
|
||||
it("reloads without the rate guard when the gateway is reachable", async () => {
|
||||
const reload = vi.fn();
|
||||
await expect(retryStaleChunkReload({ probeDocument: async () => true, reload })).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
stubDocumentFetch(new Response(null, { status: 200 }));
|
||||
await expect(retryStaleChunkReload({ reload })).resolves.toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reload while the gateway is unreachable", async () => {
|
||||
const reload = vi.fn();
|
||||
await expect(retryStaleChunkReload({ probeDocument: async () => false, reload })).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
stubDocumentFetch(new Response(null, { status: 503 }));
|
||||
await expect(retryStaleChunkReload({ reload })).resolves.toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
|
||||
const RELOAD_GUARD_STORAGE_KEY = "openclaw.controlUi.staleChunkReloadBuildId";
|
||||
// Bounds document probes across rapid re-renders of the same error state.
|
||||
const ATTEMPT_COOLDOWN_MS = 5_000;
|
||||
// Keep timeout below the cooldown so a timed-out retry re-render cannot start
|
||||
// another probe immediately while the gateway is still unreachable.
|
||||
const DOCUMENT_PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
const MODULE_IMPORT_ERROR_PATTERNS = [
|
||||
/importing a module script failed/i, // WebKit
|
||||
@@ -25,11 +28,11 @@ type StaleChunkReloadDeps = {
|
||||
now?: () => number;
|
||||
buildId?: string;
|
||||
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
||||
probeDocument?: () => Promise<boolean>;
|
||||
reload?: () => void;
|
||||
};
|
||||
|
||||
let lastAttemptAt: number | null = null;
|
||||
let inFlightDocumentProbe: Promise<boolean> | null = null;
|
||||
|
||||
export function isStaleChunkImportError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -51,13 +54,33 @@ function sessionStorageOrNull(): Pick<Storage, "getItem" | "setItem"> | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function probeControlUiDocument(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(window.location.href, { method: "HEAD", cache: "no-store" });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
function probeControlUiDocument(): Promise<boolean> {
|
||||
if (inFlightDocumentProbe) {
|
||||
return inFlightDocumentProbe;
|
||||
}
|
||||
const probe = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DOCUMENT_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(window.location.href, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
const settledProbe = probe.finally(() => {
|
||||
if (inFlightDocumentProbe === settledProbe) {
|
||||
inFlightDocumentProbe = null;
|
||||
}
|
||||
});
|
||||
inFlightDocumentProbe = settledProbe;
|
||||
return settledProbe;
|
||||
}
|
||||
|
||||
function readGuardBuildId(storage: Pick<Storage, "getItem" | "setItem"> | null): string | null {
|
||||
@@ -104,7 +127,7 @@ export async function scheduleStaleChunkReload(deps: StaleChunkReloadDeps = {}):
|
||||
if (readGuardBuildId(storage) === buildId) {
|
||||
return false;
|
||||
}
|
||||
if (!(await (deps.probeDocument ?? probeControlUiDocument)())) {
|
||||
if (!(await probeControlUiDocument())) {
|
||||
return false;
|
||||
}
|
||||
// A reload resets the in-memory state, so without a persisted guard a broken
|
||||
@@ -123,7 +146,7 @@ export async function scheduleStaleChunkReload(deps: StaleChunkReloadDeps = {}):
|
||||
* recoverable panel error with a fatal navigation error in app webviews.
|
||||
*/
|
||||
export async function retryStaleChunkReload(deps: StaleChunkReloadDeps = {}): Promise<boolean> {
|
||||
if (!(await (deps.probeDocument ?? probeControlUiDocument)())) {
|
||||
if (!(await probeControlUiDocument())) {
|
||||
return false;
|
||||
}
|
||||
(deps.reload ?? reloadControlUiDocument)();
|
||||
@@ -132,6 +155,7 @@ export async function retryStaleChunkReload(deps: StaleChunkReloadDeps = {}): Pr
|
||||
|
||||
export function resetStaleChunkReloadStateForTest(): void {
|
||||
lastAttemptAt = null;
|
||||
inFlightDocumentProbe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user