Skip to content

Commit 5ccc671

Browse files
committed
fix(update): bound GUI retries and remove sync backoff
Follow-up to #77 (review findings): - Dashboard.tsx: cap auto-retry at 2 (counter no longer resets on latest_unavailable responses), retain the retry timer id, guard every resume point with a request epoch so stale responses and timers from a closed dialog or switched channel are dropped, keep the spinner on across the backoff window so Retry stays disabled, and route X/Cancel/ success through closeUpdateDialog() cleanup. - src/update/index.ts: restore one-attempt latestVersion; the in-process retry loop busy-waited (while Date.now() < until) on the shared Bun.serve event loop for up to ~37.5s. Registry retries are GUI-owned.
1 parent 19cfff3 commit 5ccc671

2 files changed

Lines changed: 57 additions & 39 deletions

File tree

gui/src/pages/Dashboard.tsx

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ import { modelLabel } from "../model-display";
6666
const SEARCH_SIDECAR_MODELS = ["gpt-5.6-luna", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.6-sol", "gpt-5.6-terra"];
6767
const VISION_SIDECAR_MODELS = ["gpt-5.6-luna", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra"];
6868
const REASONING_LEVELS = ["low", "medium", "high"];
69+
const UPDATE_CHECK_MAX_AUTO_RETRIES = 2;
70+
const UPDATE_CHECK_RETRY_BASE_MS = 800;
6971

7072
function defaultUpdateChannel(version: string | undefined): UpdateChannel {
7173
return version?.includes("-preview.") ? "preview" : "latest";
@@ -99,12 +101,22 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
99101
const [updateRestart, setUpdateRestart] = useState(true);
100102
const [updateLoading, setUpdateLoading] = useState(false);
101103
const updateRetryRef = useRef(0);
104+
const updateRetryTimerRef = useRef<number | null>(null);
105+
const updateRequestEpochRef = useRef(0);
102106
const [updateCheck, setUpdateCheck] = useState<UpdateCheckData | null>(null);
103107
const [updateError, setUpdateError] = useState<string | null>(null);
104108
const [updateJob, setUpdateJob] = useState<UpdateJob | null>(null);
105109
const [reconnecting, setReconnecting] = useState(false);
106110
const [error, setError] = useState(false);
107111

112+
useEffect(() => () => {
113+
updateRequestEpochRef.current += 1;
114+
if (updateRetryTimerRef.current !== null) {
115+
window.clearTimeout(updateRetryTimerRef.current);
116+
updateRetryTimerRef.current = null;
117+
}
118+
}, []);
119+
108120
useEffect(() => {
109121
const fetchData = async () => {
110122
try {
@@ -309,45 +321,67 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
309321
}
310322
};
311323

312-
const fetchUpdateCheck = async (channel: UpdateChannel) => {
324+
const fetchUpdateCheck = async (channel: UpdateChannel, resetRetry = false) => {
325+
if (resetRetry) updateRetryRef.current = 0;
326+
if (updateRetryTimerRef.current !== null) {
327+
window.clearTimeout(updateRetryTimerRef.current);
328+
updateRetryTimerRef.current = null;
329+
}
330+
const requestEpoch = ++updateRequestEpochRef.current;
313331
setUpdateLoading(true);
314332
setUpdateError(null);
315333
setUpdateCheck(null);
316334
try {
317335
const res = await fetch(`${apiBase}/api/update/check?tag=${channel}`);
318336
const data = await res.json() as UpdateCheckData | { error?: string };
319337
if (!res.ok) throw new Error("error" in data && data.error ? data.error : "update check failed");
320-
setUpdateCheck(data as UpdateCheckData);
321-
updateRetryRef.current = 0;
338+
if (requestEpoch !== updateRequestEpochRef.current) return;
339+
340+
const check = data as UpdateCheckData;
341+
setUpdateCheck(check);
342+
if (
343+
check.reason === "latest_unavailable"
344+
&& updateRetryRef.current < UPDATE_CHECK_MAX_AUTO_RETRIES
345+
) {
346+
const retry = ++updateRetryRef.current;
347+
updateRetryTimerRef.current = window.setTimeout(() => {
348+
if (requestEpoch !== updateRequestEpochRef.current) return;
349+
updateRetryTimerRef.current = null;
350+
void fetchUpdateCheck(channel);
351+
}, UPDATE_CHECK_RETRY_BASE_MS * retry);
352+
return;
353+
}
354+
355+
if (check.reason !== "latest_unavailable") updateRetryRef.current = 0;
356+
setUpdateLoading(false);
322357
} catch (err) {
358+
if (requestEpoch !== updateRequestEpochRef.current) return;
323359
setUpdateError(err instanceof Error ? err.message : String(err));
324-
} finally {
325360
setUpdateLoading(false);
361+
}
362+
};
326363

327-
setUpdateCheck(prev => {
328-
if (!prev || prev.reason !== "latest_unavailable") return prev;
329-
const max = 2;
330-
if (updateRetryRef.current >= max) return prev;
331-
updateRetryRef.current += 1;
332-
setTimeout(() => { void fetchUpdateCheck(channel); }, 800 * updateRetryRef.current);
333-
return prev;
334-
});
364+
const closeUpdateDialog = () => {
365+
updateRequestEpochRef.current += 1;
366+
if (updateRetryTimerRef.current !== null) {
367+
window.clearTimeout(updateRetryTimerRef.current);
368+
updateRetryTimerRef.current = null;
335369
}
370+
setUpdateLoading(false);
371+
setUpdateOpen(false);
336372
};
337373

338374
const openUpdateDialog = () => {
339-
updateRetryRef.current = 0;
340375
const channel = defaultUpdateChannel(health?.version);
341376
setUpdateChannel(channel);
342377
setUpdateRestart(true);
343378
setUpdateOpen(true);
344-
fetchUpdateCheck(channel);
379+
void fetchUpdateCheck(channel, true);
345380
};
346381

347382
const changeUpdateChannel = (channel: UpdateChannel) => {
348-
updateRetryRef.current = 0;
349383
setUpdateChannel(channel);
350-
fetchUpdateCheck(channel);
384+
void fetchUpdateCheck(channel, true);
351385
};
352386

353387
const runUpdate = async () => {
@@ -363,7 +397,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
363397
if (!res.ok || !data.job) throw new Error(data.error ?? "update failed to start");
364398
setUpdateJob(data.job);
365399
setReconnecting(false);
366-
setUpdateOpen(false);
400+
closeUpdateDialog();
367401
} catch (err) {
368402
setUpdateError(err instanceof Error ? err.message : String(err));
369403
}
@@ -666,7 +700,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
666700
<div className="modal-card">
667701
<div className="modal-head">
668702
<h3 id="update-title">{t("dash.updateTitle")}</h3>
669-
<button type="button" className="btn-icon" onClick={() => setUpdateOpen(false)} aria-label={t("common.cancel")}>
703+
<button type="button" className="btn-icon" onClick={closeUpdateDialog} aria-label={t("common.cancel")}>
670704
<IconX />
671705
</button>
672706
</div>
@@ -711,7 +745,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
711745
type="button"
712746
className="btn btn-ghost btn-sm"
713747
disabled={updateLoading}
714-
onClick={() => { void fetchUpdateCheck(updateChannel); }}
748+
onClick={() => { void fetchUpdateCheck(updateChannel, true); }}
715749
style={{ marginLeft: 12 }}
716750
>
717751
<IconRefresh /> {t("dash.updateRetry")}
@@ -738,7 +772,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
738772
</div>
739773
)}
740774
<div className="modal-actions">
741-
<button type="button" className="btn btn-ghost" onClick={() => setUpdateOpen(false)}>{t("common.cancel")}</button>
775+
<button type="button" className="btn btn-ghost" onClick={closeUpdateDialog}>{t("common.cancel")}</button>
742776
<button
743777
type="button"
744778
className="btn btn-primary"

src/update/index.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,27 +58,11 @@ function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
5858
return { bin: "npm.cmd", shell: true };
5959
}
6060

61-
const LATEST_VERSION_ATTEMPTS = 3;
62-
const LATEST_VERSION_RETRY_MS = 500;
63-
64-
function sleepMs(ms: number): void {
65-
const until = Date.now() + ms;
66-
while (Date.now() < until) { /* sync backoff between npm view retries */ }
67-
}
68-
6961
/** Latest published version from the registry (best-effort; null if npm isn't available). */
70-
export function latestVersion(tag: string, options?: { attempts?: number }): string | null {
71-
const attempts = Math.max(1, options?.attempts ?? LATEST_VERSION_ATTEMPTS);
62+
export function latestVersion(tag: string): string | null {
7263
const npm = npmSpawnTarget("npm");
73-
for (let attempt = 0; attempt < attempts; attempt++) {
74-
const r = spawnSync(npm.bin, ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell });
75-
if (r.status === 0) {
76-
const version = r.stdout.trim();
77-
if (version) return version;
78-
}
79-
if (attempt < attempts - 1) sleepMs(LATEST_VERSION_RETRY_MS * (attempt + 1));
80-
}
81-
return null;
64+
const r = spawnSync(npm.bin, ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell });
65+
return r.status === 0 ? (r.stdout.trim() || null) : null;
8266
}
8367

8468
/** The global-install command opencodex would run to update on this channel. */

0 commit comments

Comments
 (0)