fix(ui): bound stale-chunk recovery probes (#105795)

This commit is contained in:
morluto
2026-07-13 10:59:12 +08:00
committed by GitHub
parent d49acffa5e
commit 5f46433d62
3 changed files with 159 additions and 35 deletions

View File

@@ -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();
});

View File

@@ -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();
});
});

View File

@@ -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;
}
/**