From 5f46433d62586a76decc3bdeaef972ff4bb90e2b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:59:12 +0800 Subject: [PATCH] fix(ui): bound stale-chunk recovery probes (#105795) --- ui/src/app/router-outlet.test.ts | 27 ++++-- ui/src/app/stale-chunk-reload.test.ts | 125 +++++++++++++++++++++----- ui/src/app/stale-chunk-reload.ts | 42 +++++++-- 3 files changed, 159 insertions(+), 35 deletions(-) diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts index c647aa8b240d..d744b9910b35 100644 --- a/ui/src/app/router-outlet.test.ts +++ b/ui/src/app/router-outlet.test.ts @@ -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( + async (_input, init) => + await new Promise((_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({ 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("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(); }); diff --git a/ui/src/app/stale-chunk-reload.test.ts b/ui/src/app/stale-chunk-reload.test.ts index 93b4a5293e7e..4575462448ea 100644 --- a/ui/src/app/stale-chunk-reload.test.ts +++ b/ui/src/app/stale-chunk-reload.test.ts @@ -8,6 +8,51 @@ import { } from "./stale-chunk-reload.ts"; const GUARD_KEY = "openclaw.controlUi.staleChunkReloadBuildId"; +const PROBE_TIMEOUT_MS = 3_000; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve: (value: T) => void = () => { + throw new Error("deferred promise was not initialized"); + }; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function stubDocumentFetch(...responses: Response[]) { + const fetchMock = vi.fn(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( + async (_input, init) => + await new Promise((_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 = {}) { const store = new Map(Object.entries(initial)); @@ -19,6 +64,8 @@ function memoryStorage(initial: Record = {}) { 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(); + const fetchMock = vi.fn().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(); }); }); diff --git a/ui/src/app/stale-chunk-reload.ts b/ui/src/app/stale-chunk-reload.ts index 6420c6161fa4..036986711092 100644 --- a/ui/src/app/stale-chunk-reload.ts +++ b/ui/src/app/stale-chunk-reload.ts @@ -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 | null; - probeDocument?: () => Promise; reload?: () => void; }; let lastAttemptAt: number | null = null; +let inFlightDocumentProbe: Promise | null = null; export function isStaleChunkImportError(error: unknown): boolean { return ( @@ -51,13 +54,33 @@ function sessionStorageOrNull(): Pick | null { } } -async function probeControlUiDocument(): Promise { - try { - const response = await fetch(window.location.href, { method: "HEAD", cache: "no-store" }); - return response.ok; - } catch { - return false; +function probeControlUiDocument(): Promise { + 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 | 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 { - 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; } /**