fix(ui): make stale-chunk Reload survive the gateway restart (#111670)

* fix(ui): make stale-chunk Reload survive the gateway restart

The 'A new version is available' banner appears precisely because the
gateway was just updated — so when the user clicks Reload, that gateway
is usually still restarting. The handler probed once and, on failure,
silently fell back to revalidating the same replaced chunk, so the
button looked dead and a manual hard reload was the only way out.

Poll the document probe until the gateway answers (bounded, so an
unreachable gateway still degrades to the recoverable panel error
rather than a fatal navigation), and show a disabled 'Reloading…' state
while waiting.

* fix(ui): enforce the reload wait deadline locally

The bound was only checked after each probe settled, so it relied on the
probe timing itself out. The default probe does self-abort, but a
caller-supplied one need not, and a probe that never settles would leave
the Reload button disabled forever. Race each probe against the
remaining deadline instead.

* fix(ui): satisfy lint and deadcode gates for the reload retry

CI caught two things local oxlint does not: the wait default returned
the timer id from a Promise executor (eslint no-promise-executor-return),
and retryStaleChunkReload became an unused export once the lazy-route
button moved to the polling variant.

Wrap the executor body, and collapse the two near-identical retry paths
into one: the polling helper with timeoutMs: 0 is exactly the old
single-shot behavior, which the stylesheet-recovery banner keeps.

* fix(ui): keep the reload wait bounded across interval waits

The deadline was checked before the interval wait, so a wait that
carried past it left the next iteration taking the unbounded
single-shot branch — a probe that never settles would then strand the
disabled Reload button past its own bound. Only the first attempt may
probe from outside the window.
This commit is contained in:
Peter Steinberger
2026-07-20 00:11:01 -07:00
committed by GitHub
parent 873c2a0c32
commit f5940ee92d
6 changed files with 220 additions and 27 deletions
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Control UI update recovery:** the "A new version is available" Reload button now waits out the gateway restart that stranded the chunk and reloads as soon as it answers, instead of silently doing nothing and leaving a manual hard reload as the only way out.
- **Control UI sender identity polish:** attributed user messages show the author's real avatar in an always-visible gutter on identity-resolving gateways, sender labels drop the opaque profile-UUID suffix (new and historical transcripts), and profile-id senders resolve avatars through the canonical gateway route.
- **Control UI who's-online roster:** click the sidebar footer facepile to open a scrollable roster of everyone online, showing each person's avatar, name, and email with your own entry pinned first.
- **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI.
+16 -4
View File
@@ -172,7 +172,7 @@ describe("openclaw-router-outlet", () => {
router.stop();
});
it("schedules stale-chunk recovery and falls back to revalidation while offline", async () => {
it("waits out a restarting gateway before falling back to revalidation", async () => {
vi.useFakeTimers();
let loadCount = 0;
const fetchMock = vi.fn<typeof fetch>(
@@ -214,15 +214,27 @@ describe("openclaw-router-outlet", () => {
expect(alert?.textContent).toContain("Reload to get the latest panel");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(loadCount).toBe(1);
outlet.querySelector<HTMLButtonElement>("button")?.click();
const button = outlet.querySelector<HTMLButtonElement>("button");
button?.click();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
// The gateway restart is what stranded the chunk, so one failed probe must
// not end the retry: the click keeps waiting (and shows it) rather than
// silently degrading to a revalidation that cannot fix a replaced chunk.
await vi.advanceTimersByTimeAsync(3_000);
vi.runAllTicks();
await settleOutlet(outlet);
expect(loadCount).toBe(2);
expect(loadCount).toBe(1);
expect(button?.disabled).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Past the bounded wait it still degrades to revalidation instead of
// navigating into a fatal error page against an unreachable gateway.
await vi.advanceTimersByTimeAsync(35_000);
vi.runAllTicks();
await settleOutlet(outlet);
expect(loadCount).toBe(2);
expect(fetchMock.mock.calls.length).toBeGreaterThan(1);
outlet.remove();
router.stop();
});
+34 -7
View File
@@ -13,7 +13,7 @@ import {
} from "./router-outlet-controller.ts";
import {
isStaleChunkImportError,
retryStaleChunkReload,
retryStaleChunkReloadWhenReachable,
scheduleStaleChunkReload,
} from "./stale-chunk-reload.ts";
@@ -55,6 +55,25 @@ function renderPending() {
`;
}
/**
* Shows progress while waiting for the restarting gateway. The state lives on
* the element rather than in render state because the reload replaces the
* document; a re-render that resets the label is harmless, since the pending
* wait still reloads on its own once the gateway answers.
*/
function markButtonReloading(button: HTMLButtonElement | null): () => void {
if (!button) {
return () => {};
}
const label = button.textContent;
button.disabled = true;
button.textContent = t("lazyView.reloading");
return () => {
button.disabled = false;
button.textContent = label;
};
}
function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
retryContext: TLoadContext | undefined,
@@ -75,17 +94,25 @@ function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
}
void router.revalidate(retryContext, routeId).catch(() => undefined);
};
const handleRetry = () => {
const handleRetry = (event: Event) => {
if (!staleChunk) {
revalidate();
return;
}
// Reload only when the gateway is reachable; during a restart fall back to
// revalidation so the panel error stays recoverable inside app webviews.
void retryStaleChunkReload().then((reloading) => {
if (!reloading) {
revalidate();
// The gateway is usually still restarting when this is clicked (that update
// is what stranded the chunk), so wait for it to answer and then reload
// instead of declining on the first failed probe — a silent no-op here is
// what drives people to a manual hard reload. Reloading against an
// unreachable gateway would replace the recoverable panel error with a
// fatal navigation error in app webviews, so the wait is still bounded.
const button = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null;
const restoreButton = markButtonReloading(button);
void retryStaleChunkReloadWhenReachable().then((reloading) => {
if (reloading) {
return;
}
restoreButton();
revalidate();
});
};
// Stale-chunk failures are routine after a gateway update, so present them
+99 -7
View File
@@ -3,7 +3,7 @@ import {
installMissingStylesheetRecovery,
installStaleChunkReloadListener,
isStaleChunkImportError,
retryStaleChunkReload,
retryStaleChunkReloadWhenReachable,
scheduleStaleChunkReload,
} from "./stale-chunk-reload.ts";
@@ -196,7 +196,7 @@ describe("scheduleStaleChunkReload", () => {
vi.useFakeTimers();
const reload = vi.fn();
const fetchMock = stubHangingDocumentFetch();
const retry = retryStaleChunkReload({ reload });
const retry = retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 });
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -219,30 +219,73 @@ describe("scheduleStaleChunkReload", () => {
const storage = memoryStorage();
const automatic = scheduleStaleChunkReload({ now: () => 1000, storage, reload });
const manual = retryStaleChunkReload({ reload });
const manual = retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 });
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);
await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalledTimes(1);
});
});
describe("retryStaleChunkReload", () => {
describe("retryStaleChunkReloadWhenReachable single-shot", () => {
it("reloads without the rate guard when the gateway is reachable", async () => {
const reload = vi.fn();
stubDocumentFetch(new Response(null, { status: 200 }));
await expect(retryStaleChunkReload({ reload })).resolves.toBe(true);
await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
});
it("does not reload while the gateway is unreachable", async () => {
const reload = vi.fn();
stubDocumentFetch(new Response(null, { status: 503 }));
await expect(retryStaleChunkReload({ reload })).resolves.toBe(false);
await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
describe("retryStaleChunkReloadWhenReachable", () => {
it("reloads immediately when the gateway already answers", async () => {
const reload = vi.fn();
const probe = vi.fn().mockResolvedValue(true);
await expect(retryStaleChunkReloadWhenReachable({ reload, probe })).resolves.toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
expect(probe).toHaveBeenCalledTimes(1);
});
it("waits out a restarting gateway and then reloads", async () => {
// The stale chunk exists because the gateway just restarted, so the first
// probes legitimately fail; declining here is what stranded the user.
const reload = vi.fn();
const probe = vi
.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
const wait = vi.fn().mockResolvedValue(undefined);
await expect(
retryStaleChunkReloadWhenReachable({ reload, probe, wait, intervalMs: 5 }),
).resolves.toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
expect(probe).toHaveBeenCalledTimes(3);
expect(wait).toHaveBeenCalledTimes(2);
});
it("gives up at the deadline without navigating into an error page", async () => {
const reload = vi.fn();
const probe = vi.fn().mockResolvedValue(false);
const wait = vi.fn().mockResolvedValue(undefined);
let clock = 0;
const now = () => {
clock += 400;
return clock;
};
await expect(
retryStaleChunkReloadWhenReachable({ reload, probe, wait, now, timeoutMs: 1_000 }),
).resolves.toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
@@ -401,3 +444,52 @@ describe("installMissingStylesheetRecovery", () => {
}
});
});
describe("retryStaleChunkReloadWhenReachable deadline enforcement", () => {
it("resolves at the deadline even when the probe never settles", async () => {
vi.useFakeTimers();
try {
const reload = vi.fn();
// A caller-supplied probe need not time out itself; the bound must still
// hold or the pending UI would be stranded forever.
const probe = vi.fn(() => new Promise<boolean>(() => {}));
const pending = retryStaleChunkReloadWhenReachable({
reload,
probe,
timeoutMs: 5_000,
});
await vi.advanceTimersByTimeAsync(6_000);
await expect(pending).resolves.toBe(false);
expect(reload).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
it("never starts an unbounded probe once the wait carried past the deadline", async () => {
const reload = vi.fn();
let calls = 0;
// A second probe would hang forever; the loop must not start one, or the
// caller's disabled Reload button would be stranded past its own bound.
const probe = vi.fn(async () => {
calls += 1;
return calls === 1 ? false : new Promise<boolean>(() => {});
});
let clock = 0;
const wait = vi.fn(async () => {
clock += 10_000;
});
await expect(
retryStaleChunkReloadWhenReachable({
reload,
probe,
wait,
now: () => clock,
timeoutMs: 5_000,
}),
).resolves.toBe(false);
expect(probe).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
});
+69 -9
View File
@@ -154,17 +154,75 @@ export async function scheduleStaleChunkReload(deps: StaleChunkReloadDeps = {}):
return true;
}
// A restarting gateway is the common case behind this banner: the stale chunk
// exists precisely because the gateway was just updated. Give the restart time
// to finish rather than declining the reload on the first failed probe.
const REACHABLE_WAIT_TIMEOUT_MS = 30_000;
const REACHABLE_WAIT_INTERVAL_MS = 1_000;
/**
* User-initiated retry: bypasses the automatic-reload rate guard but keeps the
* reachability probe — reloading against an unreachable gateway replaces the
* recoverable panel error with a fatal navigation error in app webviews.
* Keeps the advertised bound local instead of trusting the probe to time out:
* the default probe aborts itself, but a caller-supplied one need not, and a
* probe that never settles would strand the caller's pending UI forever.
*/
export async function retryStaleChunkReload(deps: StaleChunkReloadDeps = {}): Promise<boolean> {
if (!(await probeControlUiDocument())) {
return false;
async function probeWithinDeadline(
probe: () => Promise<boolean>,
remainingMs: number,
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), remainingMs);
});
try {
return await Promise.race([probe(), expired]);
} finally {
clearTimeout(timer);
}
}
/**
* User-initiated retry that survives the restart which caused the stale chunk:
* poll until the gateway answers, then reload. Returns false only when it stays
* unreachable for the whole window, so callers keep the recoverable panel error
* instead of navigating into a fatal error page.
*/
export async function retryStaleChunkReloadWhenReachable(
deps: StaleChunkReloadDeps & {
timeoutMs?: number;
intervalMs?: number;
probe?: () => Promise<boolean>;
wait?: (ms: number) => Promise<void>;
} = {},
): Promise<boolean> {
const now = deps.now ?? Date.now;
const probe = deps.probe ?? probeControlUiDocument;
const wait =
deps.wait ??
((ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
}));
const intervalMs = deps.intervalMs ?? REACHABLE_WAIT_INTERVAL_MS;
const deadline = now() + (deps.timeoutMs ?? REACHABLE_WAIT_TIMEOUT_MS);
for (let attempt = 0; ; attempt += 1) {
const remaining = deadline - now();
// The interval wait can carry the loop past the deadline, so re-check here:
// only the first attempt may probe from outside the window.
if (attempt > 0 && remaining <= 0) {
return false;
}
// The first attempt always probes, so timeoutMs: 0 means "single shot"
// rather than "never ask"; the probe's own abort bounds that case.
const reachable = remaining > 0 ? await probeWithinDeadline(probe, remaining) : await probe();
if (reachable) {
(deps.reload ?? reloadControlUiDocument)();
return true;
}
if (now() >= deadline) {
return false;
}
await wait(intervalMs);
}
(deps.reload ?? reloadControlUiDocument)();
return true;
}
/**
@@ -195,7 +253,9 @@ export function installMissingStylesheetRecovery(
getComputedStyle(document.documentElement).getPropertyValue("--openclaw-css-ok").trim() ===
"1");
const schedule = deps.schedule ?? scheduleStaleChunkReload;
const retry = deps.retry ?? retryStaleChunkReload;
// Single-shot (timeoutMs: 0) keeps the stylesheet banner's existing
// behavior; only the lazy-route button waits out a restart.
const retry = deps.retry ?? (() => retryStaleChunkReloadWhenReachable({ timeoutMs: 0 }));
let detected = false;
let uninstalled = false;
let banner: HTMLDivElement | null = null;
+1
View File
@@ -280,6 +280,7 @@ export const en: TranslationMap = {
genericSubtitle: "Something went wrong while loading this panel.",
staleTitle: "A new version is available",
staleSubtitle: "OpenClaw was updated in the background. Reload to get the latest panel.",
reloading: "Reloading…",
retry: "Retry",
},
nodes: {