From 0115f44ee38c517e9ca0c3334221db067a3defe6 Mon Sep 17 00:00:00 2001 From: 47Seek <229621367+47seek@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(update):=20dashboard=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=BA=8C=E8=BF=9B=E5=88=B6=E4=B8=8B=E8=BD=BD=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=B9=B6=E6=8C=82=E6=8E=A5=E8=BF=9B=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立二进制更新从 GitHub Releases 下载约 174MB 资产,同步 POST 期间前端 只能干等,重复点击撞上 409 update_in_flight 时直接渲染更新失败。 - binary-self-update 新增 onProgress:下载流上统计字节数(Content-Length 可得时给出总量),并在 checksum/probe/swap 阶段继续回报进行中状态 - /api/update/status 暴露本进程 updateInFlight 与 binaryUpdate 实时进度 - 顶部胶囊与设置页在 POST 等待期间轮询 status,真实百分比映射进安装段 进度环,checksum/probe/swap 阶段保持进行中显示 - POST 收到 409 update_in_flight 时不再报失败:按 status 挂接另一个标签页 或 CLI/定时任务中的更新,有字节显示字节,无字节显示等待;完成仅依据 版本状态判定(跨进程持锁不翻转 dashboard 内存标志),10 分钟超时提示 手动确认 Co-Authored-By: Claude Code --- src/core/binary-self-update.ts | 37 ++++++- src/dashboard.ts | 31 +++++- src/dashboard/web/app.tsx | 130 ++++++++++++++++++++++-- src/dashboard/web/i18n.ts | 18 ++++ src/dashboard/web/settings-page.tsx | 55 ++++++++++- src/dashboard/web/update-action.ts | 142 ++++++++++++++++++++++++++- test/binary-self-update.test.ts | 53 ++++++++++ test/dashboard-update-action.test.ts | 111 ++++++++++++++++++++- 8 files changed, 561 insertions(+), 16 deletions(-) diff --git a/src/core/binary-self-update.ts b/src/core/binary-self-update.ts index 65269cae3e..6e5b6d21d1 100644 --- a/src/core/binary-self-update.ts +++ b/src/core/binary-self-update.ts @@ -68,6 +68,7 @@ import { } from 'node:fs'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; +import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { GITHUB_REPO } from './restart-report.js'; import { getReleaseStream, resolveHttpProxy } from './release-download.js'; @@ -175,6 +176,18 @@ export interface BinarySelfUpdateResult { bytes: number; } +export type BinaryUpdatePhase = 'download' | 'checksum' | 'probe' | 'swap'; + +export interface BinaryUpdateProgress { + /** Current stage. `download` events carry live byte counts; later stages + * report the fully-downloaded size so the UI stays busy past 100%. */ + phase: BinaryUpdatePhase; + bytesReceived: number; + /** Asset size from the response's Content-Length, or null when the + * (injected or chunked) stream does not announce one. */ + bytesTotal: number | null; +} + export interface BinarySelfUpdateDeps { /** Fetch a URL as a readable stream (injected for tests). */ fetchStream?: (url: string) => Promise; @@ -182,6 +195,8 @@ export interface BinarySelfUpdateDeps { fetchChecksum?: (url: string) => Promise; /** Execute the downloaded candidate before activation (injected for tests). */ probeBinary?: (path: string) => { status: number | null; signal?: NodeJS.Signals | null; error?: Error; stdout?: string | Buffer | null; stderr?: string | Buffer | null }; + /** Observe download byte counts and stage transitions (dashboard progress). */ + onProgress?: (progress: BinaryUpdateProgress) => void; } /** @@ -253,7 +268,25 @@ export async function replaceStandaloneBinary( try { const res = await fetchStream(`${base}/${asset}`); - await pipeline(res, createWriteStream(tmp)); + // The default transport resolves an IncomingMessage carrying + // Content-Length; injected test streams (and chunked responses) do not. + const headers = (res as { headers?: Record }).headers; + const declared = headers ? Number(Array.isArray(headers['content-length']) ? headers['content-length'][0] : headers['content-length']) : NaN; + const bytesTotal = Number.isFinite(declared) && declared > 0 ? declared : null; + let bytesReceived = 0; + const emit = (phase: BinaryUpdatePhase): void => { + deps.onProgress?.({ phase, bytesReceived, bytesTotal }); + }; + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytesReceived += chunk.length; + emit('download'); + callback(null, chunk); + }, + }); + emit('download'); + await pipeline(res, counter, createWriteStream(tmp)); + emit('checksum'); const expected = await fetchChecksum(`${base}/${asset}.sha256`); if (expected) { const actual = await sha256File(tmp); @@ -274,6 +307,7 @@ export async function replaceStandaloneBinary( // floor; an embedded native built on a newer distro can still fail at dlopen. // Probe the complete module graph before the atomic rename so self-update has // the same fail-safe contract as npm postinstall and install.sh. + emit('probe'); const probe = probeBinary(tmp); if (probe.error || probe.status !== 0) { const raw = probe.error?.message || probe.stderr || `exit ${probe.status ?? probe.signal ?? 'unknown'}`; @@ -285,6 +319,7 @@ export async function replaceStandaloneBinary( throw new Error(`${asset} 版本校验失败(期望 ${version},实际 ${candidateVersion || 'unknown'}),保留现有版本`); } // Atomic swap. NOT a write to `target` — that is ETXTBSY (see header). + emit('swap'); renameSync(tmp, target); return { asset, target, bytes }; } catch (error) { diff --git a/src/dashboard.ts b/src/dashboard.ts index 1fc06ffacb..033c88e75e 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -14,7 +14,7 @@ import { createConfigApi } from './core/plugins/runtime.js'; import { createHmac, randomBytes } from 'node:crypto'; import { logger } from './utils/logger.js'; import { isStandaloneBinary } from './core/self-spawn.js'; -import { currentUpdateStrategy, replaceStandaloneBinary, releaseAssetName } from './core/binary-self-update.js'; +import { currentUpdateStrategy, replaceStandaloneBinary, releaseAssetName, type BinaryUpdatePhase } from './core/binary-self-update.js'; import { gracefulProcessExitCode } from './pm2-graceful-exit.js'; import { config, isWildcardBindHost } from './config.js'; import { createCompanionApi, loadCompanionSecret, type CompanionRuntime } from './dashboard/companion-api.js'; @@ -1914,6 +1914,15 @@ let updateInFlight = false; // update, and restart requests do not reuse the removed old runtime realpath. let lastSuccessfulUpdatePlan: GlobalInstallPlan | undefined; let pendingBinaryRestart: { target: string; version: string } | undefined; +// Live progress of a standalone-binary download running in THIS dashboard +// process (the ~174MB asset makes the run request long-lived). Surfaced via +// /api/update/status for the UI poller; undefined outside a binary swap. +let binaryUpdateProgress: { + version: string; + phase: BinaryUpdatePhase; + bytesReceived: number; + bytesTotal: number | null; +} | undefined; // Local-dev counterpart: the checkout a successful /api/update/run built, and // its post-build HEAD. Pinned so the follow-up /api/update/restart applies THIS @@ -4745,6 +4754,12 @@ const server = createServer(async (req, res) => { : installPlan ? formatGlobalInstallCommand(installPlan) : null, node: checkNode(), installs: detectBotmuxInstalls(), + // In-process only: a CLI/maintenance update in another process holds + // the same file lock but does not flip this flag. The UI treats a 409 + // plus `updateInFlight === false` as a cross-process update with unknown + // progress rather than declaring success. + updateInFlight, + ...(binaryUpdateProgress ? { binaryUpdate: binaryUpdateProgress } : {}), }); } @@ -4849,7 +4864,11 @@ const server = createServer(async (req, res) => { await withFileLock(globalInstallUpdateLockTarget(), async () => { acquired = true; if (hasActiveRestartLease()) { blockedByRestart = true; return; } - await replaceStandaloneBinary(newVersion, runStrategy.target); + await replaceStandaloneBinary(newVersion, runStrategy.target, { + onProgress: (progress) => { + binaryUpdateProgress = { version: newVersion, ...progress }; + }, + }); pendingBinaryRestart = { target: runStrategy.target, version: newVersion }; }, { maxWaitMs: 2_000 }); } catch (e) { @@ -4857,6 +4876,7 @@ const server = createServer(async (req, res) => { return jsonRes(res, 500, { ok: false, error: 'install_failed', detail: e instanceof Error ? e.message : String(e) }); } finally { updateInFlight = false; + binaryUpdateProgress = undefined; } if (blockedByRestart) return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' }); return jsonRes(res, 200, { @@ -5032,7 +5052,11 @@ const server = createServer(async (req, res) => { return; } } else { - await replaceStandaloneBinary(targetVersion, binaryTarget!); + await replaceStandaloneBinary(targetVersion, binaryTarget!, { + onProgress: (progress) => { + binaryUpdateProgress = { version: targetVersion, ...progress }; + }, + }); pendingBinaryRestart = { target: binaryTarget!, version: targetVersion }; } @@ -5120,6 +5144,7 @@ const server = createServer(async (req, res) => { return; } finally { updateInFlight = false; + binaryUpdateProgress = undefined; } } diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index 8cbc2e411e..12322a7700 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -31,7 +31,18 @@ import { InfoTip } from './dashboard-components.js'; import { initFloatingScrollbars } from './floating-scrollbars.js'; import { initIconTooltips } from './icon-tooltip.js'; import { PLUGIN_PINS_CHANGED_EVENT } from './plugin-events.js'; -import { updateAndRestartBotmux, type BotmuxUpdatePhase } from './update-action.js'; +import { + updateAndRestartBotmux, + type BotmuxUpdatePhase, + type BinaryUpdateStatus, + type UpdateStatusSnapshot, + type OtherUpdateState, + updateActionErrorCode, + classifyOtherUpdate, + pollUpdateStatus, + binaryProgressRingValue, + binaryProgressText, +} from './update-action.js'; import { canonicalDashboardClientShellUrl, dashboardClientShellRedirect, @@ -70,6 +81,9 @@ type BotmuxUpdateStatus = { installs: { entries: Array<{ binPath: string }>; multiple: boolean }; runningDaemons?: Array<{ larkAppId: string; version?: string }>; runningDaemonRestartHint?: string; + // Present on backends that report live binary-download progress. + updateInFlight?: boolean; + binaryUpdate?: BinaryUpdateStatus | null; }; type NavItem = { @@ -634,6 +648,8 @@ function TopbarVersionControl(props: { const [open, setOpen] = useState(false); const [phase, setPhase] = useState('idle'); const [progress, setProgress] = useState(0); + const [binaryStatus, setBinaryStatus] = useState(null); + const [observeKind, setObserveKind] = useState(null); const [refreshing, setRefreshing] = useState(false); const [refreshFailed, setRefreshFailed] = useState(false); const [errorDetail, setErrorDetail] = useState(''); @@ -647,6 +663,14 @@ function TopbarVersionControl(props: { const actionInFlightRef = useRef(false); const reconnectTimerRef = useRef(null); const progressTimerRef = useRef(null); + const stopPollingRef = useRef<(() => void) | null>(null); + // After a 409 update_in_flight we attach to the update that blocked us, which + // may run in another tab (flag visible) or in the CLI/maintenance process + // (flag invisible — completion is detected from version state only). + const observingRef = useRef(false); + const observeRollbackRef = useRef(false); + const observeStartVersionRef = useRef(''); + const observeDeadlineRef = useRef(0); const rootRef = useRef(null); const triggerRef = useRef(null); const popoverRef = useRef(null); @@ -666,8 +690,13 @@ function TopbarVersionControl(props: { useEffect(() => { actionInFlightRef.current = false; + observingRef.current = false; + stopPollingRef.current?.(); + stopPollingRef.current = null; setPhase('idle'); setProgress(0); + setBinaryStatus(null); + setObserveKind(null); setRefreshing(false); setRefreshFailed(false); setErrorDetail(''); @@ -683,7 +712,8 @@ function TopbarVersionControl(props: { // Faux progress bar. npm install gives no reliable percentage, so we creep a // deliberately-capped bar per phase: install climbs toward 50% (the real // install ends there), restart+reconnect climbs toward ~95%; the actual - // reconnect reload finishes the job, so we never fake a 100%. Reset to 0 on + // reconnect reload finishes the job, so we never fake a 100%. A live binary + // download replaces the creep with its measured byte progress. Reset to 0 on // idle/error clears it. useEffect(() => { clearProgressTimer(); @@ -691,13 +721,18 @@ function TopbarVersionControl(props: { setProgress(0); return; } + if (phase === 'updating' && binaryStatus) { + const ring = binaryProgressRingValue(binaryStatus); + if (ring !== null) setProgress(ring); + return; + } const cap = phase === 'updating' ? 50 : 95; if (phase === 'restarting') setProgress(value => Math.max(value, 50)); progressTimerRef.current = window.setInterval(() => { setProgress(value => (value >= cap ? cap : value + Math.max(0.5, (cap - value) * 0.08))); }, 400); return () => clearProgressTimer(); - }, [phase]); + }, [phase, binaryStatus]); useEffect(() => { if (status) setRefreshFailed(status.versionLookupOk === false); @@ -747,7 +782,7 @@ function TopbarVersionControl(props: { }; }, [open]); - useEffect(() => () => { clearReconnectTimer(); clearProgressTimer(); }, []); + useEffect(() => () => { clearReconnectTimer(); clearProgressTimer(); stopPollingRef.current?.(); }, []); useEffect(() => { if (!open) return; @@ -816,6 +851,59 @@ function TopbarVersionControl(props: { reconnectTimerRef.current = window.setTimeout(() => void tick(), 3_000); }; + // Called by the status poller both while OUR OWN POST is in flight (live byte + // progress) and after a 409, while observing an update running elsewhere. + const handleStatusSnapshot = (snapshot: UpdateStatusSnapshot): void => { + if (!observingRef.current) { + setBinaryStatus(snapshot.binaryUpdate ?? null); + return; + } + if (Date.now() > observeDeadlineRef.current) { + observingRef.current = false; + actionInFlightRef.current = false; + setBinaryStatus(null); + setObserveKind(null); + setPhase('error'); + setErrorDetail(t('update.inProgressSlow')); + return; + } + // A cross-process updater never flips this dashboard's in-memory flag, so + // completion comes from version state: the new version is running (rollback + // moves current, update makes `behind` false). + const completed = observeRollbackRef.current + ? snapshot.current !== observeStartVersionRef.current + : classifyOtherUpdate(snapshot) === 'completed'; + if (completed) { + location.reload(); + return; + } + const kind = classifyOtherUpdate(snapshot); + setObserveKind(kind); + setBinaryStatus(kind === 'progress' ? snapshot.binaryUpdate ?? null : null); + }; + + const startStatusPolling = (): (() => void) => + pollUpdateStatus(fetch, handleStatusSnapshot, 1000); + + // Our POST lost to an update running in another tab/process: stay in the + // updating UI and track that update instead of reporting a failure. + const beginObserve = (isRollback: boolean): void => { + observingRef.current = true; + observeRollbackRef.current = isRollback; + observeStartVersionRef.current = status?.current ?? ''; + observeDeadlineRef.current = Date.now() + 10 * 60_000; + setBinaryStatus(null); + setObserveKind('busy'); + setPhase('updating'); + setErrorDetail(''); + }; + + const stopStatusPolling = (): void => { + stopPollingRef.current?.(); + stopPollingRef.current = null; + setBinaryStatus(null); + }; + const run = async () => { if (!behind) return; if (!automatic) { @@ -826,12 +914,18 @@ function TopbarVersionControl(props: { if (actionInFlightRef.current) return; actionInFlightRef.current = true; + observingRef.current = false; setActiveRollback(null); setRefreshFailed(false); setErrorDetail(''); + setBinaryStatus(null); + setObserveKind(null); + stopPollingRef.current?.(); + stopPollingRef.current = startStatusPolling(); try { const previousInstance = await dashboardInstance(); const result = await updateAndRestartBotmux(fetch, setPhase); + stopStatusPolling(); if (!result.restarted) { // Update installed but the restart handoff failed — surface it // directly instead of polling for a reconnect that will never come. @@ -842,6 +936,11 @@ function TopbarVersionControl(props: { } pollReconnect(previousInstance); } catch (error) { + if (updateActionErrorCode(error) === 'update_in_flight') { + beginObserve(false); + return; // the status poller keeps tracking the winner + } + stopStatusPolling(); actionInFlightRef.current = false; setPhase('error'); setErrorDetail(error instanceof Error ? error.message : String(error)); @@ -883,14 +982,25 @@ function TopbarVersionControl(props: { if (!selectedRollback || !rollbackSupported || rollbackLoading || actionInFlightRef.current) return; actionInFlightRef.current = true; + observingRef.current = false; setActiveRollback(selectedRollback); setRefreshFailed(false); setErrorDetail(''); + setBinaryStatus(null); + setObserveKind(null); + stopPollingRef.current?.(); + stopPollingRef.current = startStatusPolling(); try { const previousInstance = await dashboardInstance(); await updateAndRestartBotmux(fetch, setPhase, selectedRollback); + stopStatusPolling(); pollReconnect(previousInstance); } catch (error) { + if (updateActionErrorCode(error) === 'update_in_flight') { + beginObserve(true); + return; + } + stopStatusPolling(); actionInFlightRef.current = false; setPhase('error'); setErrorDetail(error instanceof Error ? error.message : String(error)); @@ -911,9 +1021,15 @@ function TopbarVersionControl(props: { }; const message = phase === 'updating' - ? activeRollback - ? t('update.rollbackInstalling', { version: activeRollback }) - : t('update.updating', { command }) + ? binaryStatus + ? binaryProgressText(binaryStatus, t) + : observeKind === 'busy' + ? t('update.inProgressBusy') + : observeKind === 'waiting' + ? t('update.inProgressExternal') + : activeRollback + ? t('update.rollbackInstalling', { version: activeRollback }) + : t('update.updating', { command }) : phase === 'restarting' ? activeRollback ? t('update.rollbackRestarting', { version: activeRollback }) diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index ec754c583d..7a32952d5b 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1885,6 +1885,15 @@ const zh = { 'update.healthCheckFailed': '无法确认当前 Dashboard 实例,请刷新页面后重试。', 'update.nodeTooOldAlert': '当前 Node 版本 {version} 过低(要求 ≥ {required}),请先升级 Node 再更新。', 'update.updating': '正在更新({command}),请稍候…', + 'update.binaryDownloading': '正在下载新版本 {version}…', + 'update.downloadProgress': '正在下载新版本 {version}:{received} / {total}({percent}%)', + 'update.downloadProgressUnknown': '正在下载新版本 {version}:已接收 {received}…', + 'update.binaryChecksum': '新版本 {version} 下载完成,正在校验完整性…', + 'update.binaryProbe': '正在验证新版本 {version} 在当前主机上可运行…', + 'update.binarySwap': '正在切换到新版本 {version}…', + 'update.inProgressBusy': '另一个更新正在进行中,等待其完成…', + 'update.inProgressExternal': '更新正在命令行或定时任务中进行,完成后本页将自动刷新…', + 'update.inProgressSlow': '等待更新完成超时。请确认更新结果,必要时手动重启 botmux。', 'update.installReleaseHint': '此自部署版本可切换为官方最新版;原构建文件会保留。', 'update.unsupportedInstall': '当前安装无法通过此入口更新,请检查安装位置或使用原安装方式更新。', 'update.updatedChanged': '已更新 {old} → {new}。', @@ -5199,6 +5208,15 @@ const en: Record = { 'update.healthCheckFailed': 'Could not identify the current Dashboard instance. Refresh the page and try again.', 'update.nodeTooOldAlert': 'Node {version} is too old (requires ≥ {required}). Please upgrade Node before updating.', 'update.updating': 'Updating ({command})…', + 'update.binaryDownloading': 'Downloading version {version}…', + 'update.downloadProgress': 'Downloading version {version}: {received} / {total} ({percent}%)', + 'update.downloadProgressUnknown': 'Downloading version {version}: {received} received…', + 'update.binaryChecksum': 'Version {version} downloaded; verifying checksum…', + 'update.binaryProbe': 'Verifying version {version} runs on this host…', + 'update.binarySwap': 'Switching to version {version}…', + 'update.inProgressBusy': 'Another update is in progress; waiting for it to finish…', + 'update.inProgressExternal': 'An update is running from the CLI or a scheduled task; this page reloads when it completes…', + 'update.inProgressSlow': 'Timed out waiting for the other update. Please verify the result and restart botmux manually if needed.', 'update.installReleaseHint': 'This self-deployed build can switch to the latest official release. The original build file is preserved.', 'update.unsupportedInstall': 'This install cannot be updated here. Check its install location or use the original installation method.', 'update.updatedChanged': 'Updated {old} → {new}.', diff --git a/src/dashboard/web/settings-page.tsx b/src/dashboard/web/settings-page.tsx index 769a13d1c2..00a2fef457 100644 --- a/src/dashboard/web/settings-page.tsx +++ b/src/dashboard/web/settings-page.tsx @@ -5,7 +5,12 @@ import { VcConsumerProfilesGate } from './vc-consumer-profiles-section.js'; import { useT } from './react-hooks.js'; import { mountReactPage, type PageDisposer } from './react-mount.js'; import { store } from './store.js'; -import { updateResponseNeedsRestart } from './update-action.js'; +import { + updateResponseNeedsRestart, + pollUpdateStatus, + classifyOtherUpdate, + binaryProgressText, +} from './update-action.js'; import { ui } from './ui.js'; import { confirm } from './confirm-modal.js'; import { toast } from './toast.js'; @@ -285,6 +290,7 @@ function SettingsPage() { const tr = useT(); const mountedRef = useRef(false); const timersRef = useRef>(new Set()); + const updatePollStopRef = useRef<(() => void) | null>(null); const [settings, setSettings] = useState(null); const [settingsLoaded, setSettingsLoaded] = useState(false); const [loadError, setLoadError] = useState(null); @@ -391,6 +397,8 @@ function SettingsPage() { return () => { mountedRef.current = false; clearTimers(); + updatePollStopRef.current?.(); + updatePollStopRef.current = null; }; }, [clearTimers, loadSettings]); @@ -605,11 +613,52 @@ function SettingsPage() { if (!await confirm({ title: tr('update.confirmUpdateTitle'), message: confirmMsg, confirmLabel: tr('update.btnRunUpdate'), cancelLabel: tr('update.btnCancel') })) return; setUpBusy(true); setUpMsg({ text: localDev ? tr('update.updatingLocalDev') : tr('update.updating', { command: s.updateCommand! }) }); + const stopUpdatePoll = (): void => { + updatePollStopRef.current?.(); + updatePollStopRef.current = null; + }; + // Standalone-binary updates download a ~174MB asset; surface measured byte + // progress from /api/update/status while the long POST is in flight. + updatePollStopRef.current = pollUpdateStatus(fetch, snap => { + if (mountedRef.current && snap.binaryUpdate) { + setUpMsg({ text: binaryProgressText(snap.binaryUpdate, tr) }); + } + }, 1000); try { const r = await fetch('/api/update/run', { method: 'POST' }); const body = await r.json().catch(() => ({})); - if (!mountedRef.current) return; + if (!mountedRef.current) { stopUpdatePoll(); return; } if (!r.ok || body.ok === false) { + // Another update (another dashboard tab, the CLI, or scheduled + // maintenance) owns the shared install lock. Track it via status rather + // than reporting a failure: the cross-process flag is invisible, so + // completion is decided from version state only. + if (body?.error === 'update_in_flight') { + const deadline = Date.now() + 10 * 60_000; + const startVersion = String(s.current); + stopUpdatePoll(); + updatePollStopRef.current = pollUpdateStatus(fetch, snap => { + if (!mountedRef.current) return; + if (Date.now() > deadline) { + stopUpdatePoll(); + setUpBusy(false); + setUpMsg({ text: tr('update.inProgressSlow'), cls: 'hint-warn-inline' }); + return; + } + const kind = classifyOtherUpdate(snap); + if (kind === 'completed' || snap.current !== startVersion) { + location.reload(); + return; + } + if (kind === 'progress' && snap.binaryUpdate) { + setUpMsg({ text: binaryProgressText(snap.binaryUpdate, tr) }); + } else { + setUpMsg({ text: tr(kind === 'busy' ? 'update.inProgressBusy' : 'update.inProgressExternal') }); + } + }, 1000); + return; + } + stopUpdatePoll(); // Dirty worktree fails closed — surface the file list so the user can act. if (body?.error === 'dirty_worktree') { setUpBusy(false); @@ -621,6 +670,7 @@ function SettingsPage() { setUpMsg({ text: tr('update.updateFailed', { detail }), cls: 'hint-warn-inline' }); return; } + stopUpdatePoll(); // A restart is needed when the server says so (local-dev build always // regenerates dist/, even when HEAD didn't move) or when the version // changed (generic npm/pnpm/bun path, which sets no restartRequired). @@ -646,6 +696,7 @@ function SettingsPage() { await fetchStatus(); } } catch (e) { + stopUpdatePoll(); if (!mountedRef.current) return; setUpBusy(false); setUpMsg({ text: tr('update.updateFailed', { detail: e instanceof Error ? e.message : String(e) }), cls: 'hint-warn-inline' }); diff --git a/src/dashboard/web/update-action.ts b/src/dashboard/web/update-action.ts index f90cf914e4..4f00ea4897 100644 --- a/src/dashboard/web/update-action.ts +++ b/src/dashboard/web/update-action.ts @@ -32,9 +32,147 @@ async function responseBody(response: Response): Promise return body && typeof body === 'object' ? body as Record : {}; } -function responseError(response: Response, body: Record): Error { +function responseError(response: Response, body: Record): Error & { code?: string } { const detail = body.detail ?? body.error; - return new Error(typeof detail === 'string' ? detail : `HTTP ${response.status}`); + const error = new Error(typeof detail === 'string' ? detail : `HTTP ${response.status}`) as Error & { code?: string }; + // Machine-readable code so callers can special-case 409 update_in_flight + // (join/observe the running update) without matching on message text. + if (typeof body.error === 'string') error.code = body.error; + return error; +} + +// ── Live progress for standalone-binary updates (~174MB asset) ───────────────── + +export type BinaryUpdatePhase = 'download' | 'checksum' | 'probe' | 'swap'; + +export interface BinaryUpdateStatus { + version: string; + phase: BinaryUpdatePhase; + bytesReceived: number; + bytesTotal: number | null; +} + +/** Subset of GET /api/update/status the update flow polls. `updateInFlight` + * is strictly in-process: an update running in the CLI or the maintenance + * daemon holds the same file lock but leaves this flag false. */ +export interface UpdateStatusSnapshot { + current: string; + latest: string | null; + behind?: boolean; + updateInFlight?: boolean; + binaryUpdate?: BinaryUpdateStatus | null; +} + +export function updateActionErrorCode(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : null; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } + const shown = unit === 0 ? Math.round(value) : Math.round(value * 10) / 10; + return `${shown} ${units[unit]}`; +} + +/** Download fraction in 0..1, or null when the server cannot report a total + * (chunked response). */ +export function binaryDownloadFraction(status: BinaryUpdateStatus): number | null { + if (status.phase !== 'download' || !status.bytesTotal || status.bytesTotal <= 0) return null; + return Math.min(1, status.bytesReceived / status.bytesTotal); +} + +/** Map server progress onto the install half (5..48%) of the top-bar ring: + * download owns 5..45%, then checksum/probe/swap hold at 46/47/48 until the + * restart phase takes over at 50%. Null keeps the existing faux creep. */ +export function binaryProgressRingValue(status: BinaryUpdateStatus): number | null { + const fraction = binaryDownloadFraction(status); + if (fraction !== null) return 5 + fraction * 40; + if (status.phase === 'checksum') return 46; + if (status.phase === 'probe') return 47; + if (status.phase === 'swap') return 48; + return null; +} + +const BINARY_PHASE_LABEL_KEY: Record = { + download: 'update.binaryDownloading', + checksum: 'update.binaryChecksum', + probe: 'update.binaryProbe', + swap: 'update.binarySwap', +}; + +type Translate = (key: string, vars?: Record) => string; + +/** Human-readable, locale-aware line for a binary-update status snapshot. */ +export function binaryProgressText(status: BinaryUpdateStatus, t: Translate): string { + const version = `v${status.version}`; + if (status.phase === 'download') { + const fraction = binaryDownloadFraction(status); + if (fraction !== null) { + return t('update.downloadProgress', { + version, + received: formatBytes(status.bytesReceived), + total: formatBytes(status.bytesTotal ?? 0), + percent: Math.round(fraction * 100), + }); + } + if (status.bytesReceived > 0) { + return t('update.downloadProgressUnknown', { + version, + received: formatBytes(status.bytesReceived), + }); + } + } + return t(BINARY_PHASE_LABEL_KEY[status.phase], { version }); +} + +/** Poll GET /api/update/status while a (possibly cross-tab or cross-process) + * update runs. Returns a stop function. Errors are swallowed: the dashboard + * becomes briefly unreachable during the restart handoff. */ +export function pollUpdateStatus( + fetchImpl: FetchLike, + onStatus: (status: UpdateStatusSnapshot) => void, + intervalMs = 1000, +): () => void { + let stopped = false; + let timer: ReturnType | null = null; + const tick = async (): Promise => { + try { + const response = await fetchImpl('/api/update/status', { cache: 'no-store' }); + if (response.ok) { + const body = await response.json().catch(() => null); + if (!stopped && body && typeof body === 'object') { + onStatus(body as UpdateStatusSnapshot); + } + } + } catch { /* transient — poll again */ } + if (!stopped) timer = setTimeout(tick, intervalMs); + }; + timer = setTimeout(tick, 0); + return () => { + stopped = true; + if (timer !== null) clearTimeout(timer); + }; +} + +export type OtherUpdateState = 'progress' | 'busy' | 'waiting' | 'completed'; + +/** + * Interpret a status snapshot AFTER our own POST was rejected with 409 + * update_in_flight. Completion is decided ONLY from version state + * (`behind === false`), never from `updateInFlight === false`: an update in the + * CLI/maintenance process never flips this dashboard's in-memory flag. + */ +export function classifyOtherUpdate(status: UpdateStatusSnapshot): OtherUpdateState { + if (status.binaryUpdate && typeof status.binaryUpdate.phase === 'string') return 'progress'; + if (status.updateInFlight) return 'busy'; + if (status.latest && status.behind === false) return 'completed'; + return 'waiting'; } /** Install the latest, or atomically roll back to an allow-listed older version. */ diff --git a/test/binary-self-update.test.ts b/test/binary-self-update.test.ts index d91be3b38a..5855af8f72 100644 --- a/test/binary-self-update.test.ts +++ b/test/binary-self-update.test.ts @@ -37,6 +37,7 @@ import { resolveUpdateStrategy, } from '../src/core/binary-install-shape.js'; import { isMuslHost, releaseAssetName, releaseAssetBaseUrl, replaceStandaloneBinary } from '../src/core/binary-self-update.js'; +import type { BinaryUpdateProgress } from '../src/core/binary-self-update.js'; import { buildRestartLauncher, resolveStandaloneRestartExecutable, resolveRestartInvocation, verifyBinaryRestartTarget } from '../src/core/maintenance.js'; import { tryResolveGlobalInstallPlan, formatGlobalInstallCommand, resolveAutoUpdateSupport } from '../src/utils/global-install.js'; import { withFileLock, FileLockTimeoutError } from '../src/utils/file-lock.js'; @@ -926,6 +927,58 @@ describe('replaceStandaloneBinary — atomic swap of a live executable', () => { expect(statSync(target).mode & 0o111).not.toBe(0); }); + it('emits download byte progress and ordered stage transitions', async () => { + const dir = tmp(); + const target = join(dir, 'botmux'); + writeFileSync(target, 'OLD BINARY', { mode: 0o755 }); + const payload = fakeAsset(); + const events: BinaryUpdateProgress[] = []; + const stream = Object.assign(Readable.from([payload]), { + headers: { 'content-length': String(payload.length) }, + }); + await replaceStandaloneBinary('3.99.0', target, { + fetchStream: async () => stream, + fetchChecksum: async () => null, + probeBinary: probeOk, + onProgress: event => events.push({ ...event }), + }); + const stages = events.map(e => e.phase); + expect(stages[0]).toBe('download'); + expect(stages).toContain('checksum'); + expect(stages).toContain('probe'); + expect(stages[stages.length - 1]).toBe('swap'); + const downloads = events.filter(e => e.phase === 'download'); + expect(downloads[0].bytesTotal).toBe(BIG); + expect(downloads[downloads.length - 1].bytesReceived).toBe(BIG); + // Counts are monotonic and never exceed the declared total. + let previous = -1; + for (const event of downloads) { + expect(event.bytesReceived).toBeGreaterThan(previous); + expect(event.bytesReceived).toBeLessThanOrEqual(BIG); + previous = event.bytesReceived; + } + // Post-download stages keep the full size so the UI stays busy past 100%. + for (const phase of ['checksum', 'probe', 'swap'] as const) { + expect(events.find(e => e.phase === phase)?.bytesReceived).toBe(BIG); + } + }); + + it('reports null bytesTotal when the stream announces no Content-Length', async () => { + const dir = tmp(); + const target = join(dir, 'botmux'); + writeFileSync(target, 'OLD BINARY', { mode: 0o755 }); + const payload = fakeAsset(); + const events: BinaryUpdateProgress[] = []; + await replaceStandaloneBinary('3.99.0', target, { + fetchStream: async () => Readable.from([payload]), + fetchChecksum: async () => null, + probeBinary: probeOk, + onProgress: event => events.push({ ...event }), + }); + expect(events[0].bytesTotal).toBeNull(); + expect(events.at(-1)?.phase).toBe('swap'); + }); + it('a checksum mismatch leaves the WORKING binary in place', async () => { const dir = tmp(); const target = join(dir, 'botmux'); diff --git a/test/dashboard-update-action.test.ts b/test/dashboard-update-action.test.ts index 3249338b85..d0da19f8e7 100644 --- a/test/dashboard-update-action.test.ts +++ b/test/dashboard-update-action.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import { updateAndRestartBotmux, updateResponseNeedsRestart } from '../src/dashboard/web/update-action.js'; +import { + updateAndRestartBotmux, + updateResponseNeedsRestart, + updateActionErrorCode, + formatBytes, + binaryDownloadFraction, + binaryProgressRingValue, + binaryProgressText, + classifyOtherUpdate, + pollUpdateStatus, + type BinaryUpdateStatus, +} from '../src/dashboard/web/update-action.js'; function json(status: number, body: unknown): Response { return Response.json(body, { status }); @@ -145,6 +156,17 @@ describe('dashboard update and restart action', () => { await expect(updateAndRestartBotmux(fetchImpl)).rejects.toThrow('Invalid update response'); expect(fetchImpl).toHaveBeenCalledTimes(1); }); + + it('carries the server error code on rejection so 409 update_in_flight is detectable', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(json(409, { ok: false, error: 'update_in_flight' })); + + const error = await updateAndRestartBotmux(fetchImpl) + .then(() => null, (e: unknown) => e); + expect(updateActionErrorCode(error)).toBe('update_in_flight'); + expect(error).toBeInstanceOf(Error); + expect(fetchImpl).toHaveBeenCalledTimes(1); // a busy lock must not trigger the restart call + }); }); describe('updateResponseNeedsRestart', () => { @@ -163,3 +185,90 @@ describe('updateResponseNeedsRestart', () => { expect(updateResponseNeedsRestart({})).toBe(false); }); }); + +describe('binary update progress helpers', () => { + const downloading = (received: number, total: number | null): BinaryUpdateStatus => ({ + version: '3.41.0', phase: 'download', bytesReceived: received, bytesTotal: total, + }); + + it('formats byte sizes in B/KB/MB/GB', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2 KB'); + expect(formatBytes(Math.round(1.5 * 1024 * 1024))).toBe('1.5 MB'); + expect(formatBytes(174 * 1024 * 1024)).toBe('174 MB'); + }); + + it('maps a live download onto the install half of the ring', () => { + expect(binaryProgressRingValue(downloading(0, 100))).toBe(5); + expect(binaryProgressRingValue(downloading(50, 100))).toBe(25); + expect(binaryProgressRingValue(downloading(100, 100))).toBe(45); + // Never exceed the download segment even if the server miscounts. + expect(binaryProgressRingValue(downloading(500, 100))).toBe(45); + // Post-download stages hold just below the restart segment. + expect(binaryProgressRingValue({ ...downloading(100, 100), phase: 'checksum' })).toBe(46); + expect(binaryProgressRingValue({ ...downloading(100, 100), phase: 'probe' })).toBe(47); + expect(binaryProgressRingValue({ ...downloading(100, 100), phase: 'swap' })).toBe(48); + }); + + it('falls back to no ring value without a Content-Length total', () => { + expect(binaryDownloadFraction(downloading(1234, null))).toBeNull(); + expect(binaryProgressRingValue(downloading(1234, null))).toBeNull(); + }); + + it('renders measured progress text, and stage labels past 100%', () => { + const vars: Array> = []; + const fakeT = (key: string, params?: Record): string => { + vars.push(params ?? {}); + return `${key}|${JSON.stringify(params ?? {})}`; + }; + const text = binaryProgressText(downloading(50 * 1024 * 1024, 100 * 1024 * 1024), fakeT); + expect(text).toContain('update.downloadProgress'); + expect(text).toContain('"percent":50'); + expect(binaryProgressText(downloading(1234, null), fakeT)).toContain('update.downloadProgressUnknown'); + expect(binaryProgressText({ ...downloading(100, 100), phase: 'checksum' }, fakeT)).toContain('update.binaryChecksum'); + expect(vars.every(v => v.version === 'v3.41.0')).toBe(true); + }); +}); + +describe('classifyOtherUpdate after a 409', () => { + it('exposes live progress when the blocking update is in this dashboard process', () => { + const binary = { version: '3.41.0', phase: 'download', bytesReceived: 1, bytesTotal: 100 } as const; + expect(classifyOtherUpdate({ current: '3.40.0', latest: '3.41.0', updateInFlight: true, binaryUpdate: binary })) + .toBe('progress'); + }); + + it('reports busy for an in-process npm/local-dev update that has no byte progress', () => { + expect(classifyOtherUpdate({ current: '3.40.0', latest: '3.41.0', updateInFlight: true })) + .toBe('busy'); + }); + + it('never claims completion from the in-process flag alone — a cross-process lock holder leaves it false', () => { + // The regression shape: CLI/maintenance holds the lock; this dashboard's + // flag stays false and versions are still behind. + expect(classifyOtherUpdate({ current: '3.40.0', latest: '3.41.0', behind: true, updateInFlight: false })) + .toBe('waiting'); + }); + + it('detects completion only from version state', () => { + expect(classifyOtherUpdate({ current: '3.41.0', latest: '3.41.0', behind: false, updateInFlight: false })) + .toBe('completed'); + }); +}); + +describe('pollUpdateStatus', () => { + it('pushes parsed status snapshots until stopped', async () => { + const snapshots: unknown[] = []; + const fetchImpl = vi.fn().mockResolvedValue(Response.json({ + current: '3.40.0', latest: '3.41.0', updateInFlight: true, + })); + const stop = pollUpdateStatus(fetchImpl, s => snapshots.push(s), 5); + await new Promise(resolve => setTimeout(resolve, 20)); + stop(); + const callsAfterStop = fetchImpl.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 20)); + expect(snapshots.length).toBeGreaterThan(0); + expect((snapshots[0] as { updateInFlight: boolean }).updateInFlight).toBe(true); + expect(fetchImpl.mock.calls.length).toBe(callsAfterStop); + }); +}); From 7cc818975baaced4a8f5686b60f55ef1a80d2dda Mon Sep 17 00:00:00 2001 From: 47Seek <229621367+47seek@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:08:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(update):=20=E8=A7=82=E5=AF=9F=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E6=9B=B4=E6=96=B0=E6=97=B6=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E4=BA=8E=E5=93=8D=E5=BA=94=E4=B8=94=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=AE=8C=E6=88=90=E4=B8=8D=E5=BC=BA=E5=88=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 两项 P2: 1. 持续 503、网络异常或请求挂起时,超时判断位于 status 成功回调内, 10 分钟超时永远无法触发,顶部即使进入超时分支也不会停轮询。 pollUpdateStatus 的 timeoutMs/onTimeout 改由独立定时器驱动,触发时 先自动停止轮询再回调;两处观察入口(顶部胶囊、设置页)接入。 2. 观察到版本状态完成(behind=false/current 变化)只代表磁盘上安装 完成,dashboard 实例并未变化:胜方可能仍停在设置页重启确认上,CLI 更新则根本不重启。顶部胶囊不再直接 reload,改转 restarting 阶段, 复用 /__selfcheck 实例变化判据(90s 无变化提示手动刷新);设置页 观察完成只刷新状态并提示重启后生效,不打断既有重启确认。完成判据 抽为 observedInstallCompleted 纯函数,两处复用。 新增 8 个测试:超时在持续失败/持续 503 下仍触发且自动停止、stop 后 不触发;observedInstallCompleted 更新/回退各场景。共 108 项 Vitest 与 bun test 通过。 Co-Authored-By: Claude Code --- src/dashboard/web/app.tsx | 78 +++++++++++++++++------- src/dashboard/web/settings-page.tsx | 27 +++++---- src/dashboard/web/update-action.ts | 47 +++++++++++++-- test/dashboard-update-action.test.ts | 89 ++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 37 deletions(-) diff --git a/src/dashboard/web/app.tsx b/src/dashboard/web/app.tsx index 12322a7700..245f9362db 100644 --- a/src/dashboard/web/app.tsx +++ b/src/dashboard/web/app.tsx @@ -39,6 +39,7 @@ import { type OtherUpdateState, updateActionErrorCode, classifyOtherUpdate, + observedInstallCompleted, pollUpdateStatus, binaryProgressRingValue, binaryProgressText, @@ -666,11 +667,12 @@ function TopbarVersionControl(props: { const stopPollingRef = useRef<(() => void) | null>(null); // After a 409 update_in_flight we attach to the update that blocked us, which // may run in another tab (flag visible) or in the CLI/maintenance process - // (flag invisible — completion is detected from version state only). + // (flag invisible — install completion is detected from version state, and + // the actual reload only after the dashboard instance changes). const observingRef = useRef(false); const observeRollbackRef = useRef(false); const observeStartVersionRef = useRef(''); - const observeDeadlineRef = useRef(0); + const observeInstanceRef = useRef(''); const rootRef = useRef(null); const triggerRef = useRef(null); const popoverRef = useRef(null); @@ -858,23 +860,21 @@ function TopbarVersionControl(props: { setBinaryStatus(snapshot.binaryUpdate ?? null); return; } - if (Date.now() > observeDeadlineRef.current) { + // A cross-process updater never flips this dashboard's in-memory flag, so + // install completion comes from version state: rollback moves current, an + // update makes `behind` false. But "installed on disk" is NOT "running": + // the winner may still be sitting on a restart confirmation (Settings) or + // be a CLI update that never restarts. So we only switch to the restart + // phase here and let the /__selfcheck instance watcher decide about the + // reload — never reload on version state alone. + if (observedInstallCompleted(snapshot, observeStartVersionRef.current, observeRollbackRef.current)) { observingRef.current = false; - actionInFlightRef.current = false; + stopPollingRef.current?.(); + stopPollingRef.current = null; setBinaryStatus(null); setObserveKind(null); - setPhase('error'); - setErrorDetail(t('update.inProgressSlow')); - return; - } - // A cross-process updater never flips this dashboard's in-memory flag, so - // completion comes from version state: the new version is running (rollback - // moves current, update makes `behind` false). - const completed = observeRollbackRef.current - ? snapshot.current !== observeStartVersionRef.current - : classifyOtherUpdate(snapshot) === 'completed'; - if (completed) { - location.reload(); + setPhase('restarting'); + pollReconnect(observeInstanceRef.current); return; } const kind = classifyOtherUpdate(snapshot); @@ -882,20 +882,36 @@ function TopbarVersionControl(props: { setBinaryStatus(kind === 'progress' ? snapshot.binaryUpdate ?? null : null); }; + // Progress-only polling while our own POST is in flight (no timeout: the + // POST settling ends it). const startStatusPolling = (): (() => void) => pollUpdateStatus(fetch, handleStatusSnapshot, 1000); + // Polling after a 409: bounded by an independent timer so persistent 503s / + // dead network / a hung request still reach a terminal state. + const startObservePolling = (): (() => void) => + pollUpdateStatus(fetch, handleStatusSnapshot, 1000, 10 * 60_000, () => { + observingRef.current = false; + actionInFlightRef.current = false; + setBinaryStatus(null); + setObserveKind(null); + setPhase('error'); + setErrorDetail(t('update.inProgressSlow')); + }); + // Our POST lost to an update running in another tab/process: stay in the // updating UI and track that update instead of reporting a failure. - const beginObserve = (isRollback: boolean): void => { + const beginObserve = (isRollback: boolean, instance: string): void => { observingRef.current = true; observeRollbackRef.current = isRollback; observeStartVersionRef.current = status?.current ?? ''; - observeDeadlineRef.current = Date.now() + 10 * 60_000; + observeInstanceRef.current = instance; setBinaryStatus(null); setObserveKind('busy'); setPhase('updating'); setErrorDetail(''); + stopPollingRef.current?.(); + stopPollingRef.current = startObservePolling(); }; const stopStatusPolling = (): void => { @@ -922,8 +938,17 @@ function TopbarVersionControl(props: { setObserveKind(null); stopPollingRef.current?.(); stopPollingRef.current = startStatusPolling(); + let previousInstance: string; + try { + previousInstance = await dashboardInstance(); + } catch (error) { + stopStatusPolling(); + actionInFlightRef.current = false; + setPhase('error'); + setErrorDetail(error instanceof Error ? error.message : String(error)); + return; + } try { - const previousInstance = await dashboardInstance(); const result = await updateAndRestartBotmux(fetch, setPhase); stopStatusPolling(); if (!result.restarted) { @@ -937,7 +962,7 @@ function TopbarVersionControl(props: { pollReconnect(previousInstance); } catch (error) { if (updateActionErrorCode(error) === 'update_in_flight') { - beginObserve(false); + beginObserve(false, previousInstance); return; // the status poller keeps tracking the winner } stopStatusPolling(); @@ -990,14 +1015,23 @@ function TopbarVersionControl(props: { setObserveKind(null); stopPollingRef.current?.(); stopPollingRef.current = startStatusPolling(); + let previousInstance: string; + try { + previousInstance = await dashboardInstance(); + } catch (error) { + stopStatusPolling(); + actionInFlightRef.current = false; + setPhase('error'); + setErrorDetail(error instanceof Error ? error.message : String(error)); + return; + } try { - const previousInstance = await dashboardInstance(); await updateAndRestartBotmux(fetch, setPhase, selectedRollback); stopStatusPolling(); pollReconnect(previousInstance); } catch (error) { if (updateActionErrorCode(error) === 'update_in_flight') { - beginObserve(true); + beginObserve(true, previousInstance); return; } stopStatusPolling(); diff --git a/src/dashboard/web/settings-page.tsx b/src/dashboard/web/settings-page.tsx index 00a2fef457..5837d90c28 100644 --- a/src/dashboard/web/settings-page.tsx +++ b/src/dashboard/web/settings-page.tsx @@ -9,6 +9,7 @@ import { updateResponseNeedsRestart, pollUpdateStatus, classifyOtherUpdate, + observedInstallCompleted, binaryProgressText, } from './update-action.js'; import { ui } from './ui.js'; @@ -632,22 +633,21 @@ function SettingsPage() { // Another update (another dashboard tab, the CLI, or scheduled // maintenance) owns the shared install lock. Track it via status rather // than reporting a failure: the cross-process flag is invisible, so - // completion is decided from version state only. + // install completion is decided from version state. "Installed" does NOT + // reload the page — the winner may be sitting on its own restart + // confirmation, and a CLI update never restarts; surface the pending + // restart and let the existing restart flow apply it. if (body?.error === 'update_in_flight') { - const deadline = Date.now() + 10 * 60_000; const startVersion = String(s.current); stopUpdatePoll(); updatePollStopRef.current = pollUpdateStatus(fetch, snap => { if (!mountedRef.current) return; - if (Date.now() > deadline) { + const kind = classifyOtherUpdate(snap); + if (observedInstallCompleted(snap, startVersion, false)) { stopUpdatePoll(); setUpBusy(false); - setUpMsg({ text: tr('update.inProgressSlow'), cls: 'hint-warn-inline' }); - return; - } - const kind = classifyOtherUpdate(snap); - if (kind === 'completed' || snap.current !== startVersion) { - location.reload(); + setUpMsg({ text: tr('update.noRestartHint'), cls: 'hint-ok' }); + void fetchStatus(); return; } if (kind === 'progress' && snap.binaryUpdate) { @@ -655,7 +655,14 @@ function SettingsPage() { } else { setUpMsg({ text: tr(kind === 'busy' ? 'update.inProgressBusy' : 'update.inProgressExternal') }); } - }, 1000); + }, 1000, 10 * 60_000, () => { + // Independent of status responses: persistent 503s, a dead + // network, or a hung request must still reach this terminal state. + if (!mountedRef.current) return; + updatePollStopRef.current = null; + setUpBusy(false); + setUpMsg({ text: tr('update.inProgressSlow'), cls: 'hint-warn-inline' }); + }); return; } stopUpdatePoll(); diff --git a/src/dashboard/web/update-action.ts b/src/dashboard/web/update-action.ts index 4f00ea4897..35b4dc4feb 100644 --- a/src/dashboard/web/update-action.ts +++ b/src/dashboard/web/update-action.ts @@ -133,15 +133,32 @@ export function binaryProgressText(status: BinaryUpdateStatus, t: Translate): st /** Poll GET /api/update/status while a (possibly cross-tab or cross-process) * update runs. Returns a stop function. Errors are swallowed: the dashboard - * becomes briefly unreachable during the restart handoff. */ + * becomes briefly unreachable during the restart handoff. + * + * `timeoutMs`/`onTimeout` run on an INDEPENDENT timer, so a server that keeps + * returning 503, a dead network, or a request that hangs forever still reaches + * the terminal state. On timeout polling stops itself before invoking + * `onTimeout` — the callback must not keep polling. */ export function pollUpdateStatus( fetchImpl: FetchLike, onStatus: (status: UpdateStatusSnapshot) => void, intervalMs = 1000, + timeoutMs?: number, + onTimeout?: () => void, ): () => void { let stopped = false; let timer: ReturnType | null = null; + let timeoutTimer: ReturnType | null = null; + const clearScheduled = (): void => { + if (timer !== null) { clearTimeout(timer); timer = null; } + }; + const stop = (): void => { + stopped = true; + clearScheduled(); + if (timeoutTimer !== null) { clearTimeout(timeoutTimer); timeoutTimer = null; } + }; const tick = async (): Promise => { + timer = null; try { const response = await fetchImpl('/api/update/status', { cache: 'no-store' }); if (response.ok) { @@ -153,11 +170,14 @@ export function pollUpdateStatus( } catch { /* transient — poll again */ } if (!stopped) timer = setTimeout(tick, intervalMs); }; + if (timeoutMs !== undefined && onTimeout) { + timeoutTimer = setTimeout(() => { + stop(); + onTimeout(); + }, timeoutMs); + } timer = setTimeout(tick, 0); - return () => { - stopped = true; - if (timer !== null) clearTimeout(timer); - }; + return stop; } export type OtherUpdateState = 'progress' | 'busy' | 'waiting' | 'completed'; @@ -175,6 +195,23 @@ export function classifyOtherUpdate(status: UpdateStatusSnapshot): OtherUpdateSt return 'waiting'; } +/** + * Whether the observed update finished INSTALLING, from version state alone: + * a rollback moves `current`, an update makes `behind` false (and either may + * move `current`). This is deliberately NOT the reload gate: install + * completion precedes the restart, another tab may still be showing a restart + * confirmation, and a CLI-driven update never restarts at all. Callers gate any + * reload on the dashboard INSTANCE changing (/__selfcheck), not on this. + */ +export function observedInstallCompleted( + status: UpdateStatusSnapshot, + startVersion: string, + isRollback: boolean, +): boolean { + if (status.current !== startVersion) return true; + return !isRollback && classifyOtherUpdate(status) === 'completed'; +} + /** Install the latest, or atomically roll back to an allow-listed older version. */ export async function updateAndRestartBotmux( fetchImpl: FetchLike, diff --git a/test/dashboard-update-action.test.ts b/test/dashboard-update-action.test.ts index d0da19f8e7..0bd3480fa6 100644 --- a/test/dashboard-update-action.test.ts +++ b/test/dashboard-update-action.test.ts @@ -8,6 +8,7 @@ import { binaryProgressRingValue, binaryProgressText, classifyOtherUpdate, + observedInstallCompleted, pollUpdateStatus, type BinaryUpdateStatus, } from '../src/dashboard/web/update-action.js'; @@ -256,6 +257,41 @@ describe('classifyOtherUpdate after a 409', () => { }); }); +describe('observedInstallCompleted — install state, never a reload gate', () => { + it('update: completed once behind clears', () => { + expect(observedInstallCompleted( + { current: '3.40.0', latest: '3.41.0', behind: false }, '3.40.0', false, + )).toBe(true); + }); + + it('update: still behind with the in-process flag false is NOT completed (cross-process lock holder)', () => { + expect(observedInstallCompleted( + { current: '3.40.0', latest: '3.41.0', behind: true, updateInFlight: false }, '3.40.0', false, + )).toBe(false); + }); + + it('update: a moved current also completes even when latest is unknown', () => { + expect(observedInstallCompleted( + { current: '3.41.0', latest: null, behind: true }, '3.40.0', false, + )).toBe(true); + }); + + it('rollback: completes only when current moves to the rolled-back version', () => { + expect(observedInstallCompleted( + { current: '3.41.0', latest: '3.41.0', behind: false }, '3.41.0', true, + )).toBe(false); + expect(observedInstallCompleted( + { current: '3.40.0', latest: '3.41.0', behind: true }, '3.41.0', true, + )).toBe(true); + }); + + it('rollback ignores the update-style behind=false signal while current has not moved', () => { + expect(observedInstallCompleted( + { current: '3.41.0', latest: '3.40.0', behind: false }, '3.41.0', true, + )).toBe(false); + }); +}); + describe('pollUpdateStatus', () => { it('pushes parsed status snapshots until stopped', async () => { const snapshots: unknown[] = []; @@ -271,4 +307,57 @@ describe('pollUpdateStatus', () => { expect((snapshots[0] as { updateInFlight: boolean }).updateInFlight).toBe(true); expect(fetchImpl.mock.calls.length).toBe(callsAfterStop); }); + + it('fires the timeout even when every request fails, then stops polling', async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn().mockRejectedValue(new Error('network down')); + const onTimeout = vi.fn(); + const stop = pollUpdateStatus(fetchImpl, () => {}, 1000, 10_000, onTimeout); + await vi.advanceTimersByTimeAsync(9_999); + expect(onTimeout).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2); + expect(onTimeout).toHaveBeenCalledTimes(1); + // Timeout is terminal: the poller stopped itself. + const callsAtTimeout = fetchImpl.mock.calls.length; + await vi.advanceTimersByTimeAsync(5_000); + expect(fetchImpl.mock.calls.length).toBe(callsAtTimeout); + stop(); // stopping again is a no-op, not a second timeout + expect(onTimeout).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('fires the timeout while the server keeps answering non-200 (no successful snapshot)', async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn().mockResolvedValue(Response.json({ error: 'unavailable' }, { status: 503 })); + const onStatus = vi.fn(); + const onTimeout = vi.fn(); + pollUpdateStatus(fetchImpl, onStatus, 1000, 5_000, onTimeout); + await vi.advanceTimersByTimeAsync(5_001); + expect(onStatus).not.toHaveBeenCalled(); + expect(onTimeout).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('never fires the timeout after an explicit stop (update finished normally)', async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn().mockResolvedValue(Response.json({ + current: '3.40.0', latest: '3.41.0', behind: false, + })); + const onTimeout = vi.fn(); + const stop = pollUpdateStatus(fetchImpl, () => {}, 1000, 10_000, onTimeout); + await vi.advanceTimersByTimeAsync(2_000); + stop(); + await vi.advanceTimersByTimeAsync(20_000); + expect(onTimeout).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); });