From 647188f74eb961731a6dcc0f502b3e0379d10d26 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 5 Oct 2026 22:52:02 +0800 Subject: [PATCH] fix(desktop): fence stale update status responses Signed-off-by: Duang777 --- .../src-tauri/src/maintenance.rs | 18 +++++- .../loopx-control-plane/static/boot.js | 46 +++++++++---- .../personal-workspace/desktop-update.tsx | 47 ++++++++++---- examples/desktop-update-browser-smoke.mjs | 64 ++++++++++++++++++- 4 files changed, 145 insertions(+), 30 deletions(-) diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index ed6ed401ce..f835233562 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -265,8 +265,6 @@ fn detect_environment() -> Value { #[tauri::command] pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> Value { - let snapshot = state.status_snapshot(); - let last_failure = state.last_failure.lock().unwrap().clone(); // Probing spawns bounded sub-processes; the boot page polls every second, // so serve the cached block and refresh at most every ENVIRONMENT_TTL. let environment = { @@ -278,7 +276,21 @@ pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> V cache.as_ref().expect("refreshed above").1.clone() }; let selection = crate::runtime_selection::selected(&app).ok(); - json!({"state": snapshot, "startup": state.startup_timing(), "last_failure": last_failure, "app_version": app.package_info().version.to_string(), "runtime": bundled_runtime::identity(&app).ok(), "runtime_selection":{"explicit":selection.as_ref().is_some_and(|selected| selected.environment_override), "remembered":selection.as_ref().is_some_and(|selected| selected.explicit), "bundled_repair_available":selection.is_some() && !state.separately_managed_runtime.load(Ordering::Acquire)}, "rollback_available": crate::update_backup::available(&app), "environment": environment}) + let runtime = bundled_runtime::identity(&app).ok(); + let runtime_selection = json!({ + "explicit": selection.as_ref().is_some_and(|selected| selected.environment_override), + "remembered": selection.as_ref().is_some_and(|selected| selected.explicit), + "bundled_repair_available": selection.is_some() + && !state.separately_managed_runtime.load(Ordering::Acquire), + }); + let rollback_available = crate::update_backup::available(&app); + let app_version = app.package_info().version.to_string(); + let startup = state.startup_timing(); + // Read mutable transaction state after the potentially blocking + // diagnostics so this response cannot revive a superseded phase. + let snapshot = state.status_snapshot(); + let last_failure = state.last_failure.lock().unwrap().clone(); + json!({"state": snapshot, "startup": startup, "last_failure": last_failure, "app_version": app_version, "runtime": runtime, "runtime_selection": runtime_selection, "rollback_available": rollback_available, "environment": environment}) } #[tauri::command] pub async fn desktop_update( diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index 96b0a4a5f3..fe8f06932e 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -67,9 +67,12 @@ const updateStatus = document.querySelector("#update-status"); const forgetSelection = document.querySelector("#forget-selection"); let nextAction = "check"; let working = false; +let actionInFlight = false; +let statusGeneration = 0; let channelInitialized = false; let runtimeExplicit = false; let bundledRepairAvailable = true; +const updateWorkingPhases = ["checking","downloading","installing_app","installing_runtime","connecting"]; document.querySelector("#retry").onclick = () => location.reload(); channel.onchange = () => { channelInitialized = true; render({phase:"idle"}); }; const labels = { @@ -133,13 +136,14 @@ function codeText(code, phase) { function render(state) { if (!state?.phase) return state; if (state.phase === "available" && state.details?.channel !== channel.value) state = {phase:"idle"}; - working = ["checking","downloading","installing_app","installing_runtime","connecting"].includes(state.phase); - update.disabled = working; - repair.disabled = working || state.phase === "restart_required" || runtimeExplicit || !bundledRepairAvailable; + working = updateWorkingPhases.includes(state.phase); + const controlsDisabled = working || actionInFlight; + update.disabled = controlsDisabled; + repair.disabled = controlsDisabled || state.phase === "restart_required" || runtimeExplicit || !bundledRepairAvailable; if (state.details?.bundled_repair_available === false) repair.disabled = true; - forgetSelection.disabled = working || state.phase === "restart_required"; - rollback.disabled = working || state.phase === "restart_required"; - channel.disabled = working || state.phase === "restart_required"; + forgetSelection.disabled = controlsDisabled || state.phase === "restart_required"; + rollback.disabled = controlsDisabled || state.phase === "restart_required"; + channel.disabled = controlsDisabled || state.phase === "restart_required"; nextAction = state.phase === "available" ? "apply" : state.phase === "restart_required" ? "restart" : "check"; update.textContent = nextAction === "apply" ? "更新并准备重启 / Install update" : nextAction === "restart" ? "重启完成更新 / Restart" : "检查更新 / Check for updates"; updateStatus.textContent = codeText(state.details?.code, state.phase); @@ -186,17 +190,24 @@ document.querySelector("#copy-diagnostics").onclick = async () => { document.querySelector("#copy-status").textContent = "请按 ⌘C / Ctrl+C 复制已选中的诊断。"; } }; -async function invokeUpdate(action) { +async function run(action) { + if (working || actionInFlight) return; + actionInFlight = true; + statusGeneration++; // Match the phase the backend publishes for each action (rollback restores // the previous app; restart keeps the required-restart state) instead of // previewing a download that is not happening. render({phase: action === "check" ? "checking" : action === "repair" || action === "align_runtime" ? "installing_runtime" : action === "forget_runtime_selection" ? "connecting" : action === "rollback" ? "installing_app" : action === "restart" ? "restart_required" : "downloading"}); - try { return render(await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value})); } - catch (error) { return render({phase:"error", details:{code: safeCode(error)}}); } -} -async function run(action) { - if (working) return; - return invokeUpdate(action); + try { + const result = await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value}); + statusGeneration++; + actionInFlight = false; + return render(result); + } catch (error) { + statusGeneration++; + actionInFlight = false; + return render({phase:"error", details:{code: safeCode(error)}}); + } } update.onclick = () => run(nextAction); repair.onclick = () => run("repair"); @@ -214,8 +225,11 @@ let terminalRounds = 0; let escalated = false; async function refresh() { if (!window.__TAURI__) { renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); return; } + const generation = ++statusGeneration; try { const result = await window.__TAURI__.core.invoke("desktop_update_status"); + if (generation !== statusGeneration) return; + if (actionInFlight && result.state?.phase && !updateWorkingPhases.includes(result.state.phase)) return; if (!channelInitialized) { channel.value = result.state?.details?.channel ?? (result.app_version?.includes("-main.") ? "main" : "stable"); channelInitialized = true; @@ -228,7 +242,11 @@ async function refresh() { render(result.state); renderStartup(result); escalateFromSnapshot(result.state); - } catch { renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); } + } catch { + if (generation === statusGeneration && !actionInFlight) { + renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); + } + } } function escalateFromSnapshot(state) { const terminal = TERMINAL_PHASES.includes(state?.phase); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx index c936ce1213..e6fe979af1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx @@ -34,15 +34,23 @@ export function DesktopUpdate() { const [rollbackAvailable, setRollbackAvailable] = useState(false); const [runtimeSelection, setRuntimeSelection] = useState(); const busy = useRef(false); + const statusGeneration = useRef(0); const invoke = (window as DesktopWindow).__TAURI__?.core.invoke; const working = workingPhases.includes(state.phase); const repairAvailable = runtimeSelection?.explicit !== true && runtimeSelection?.bundled_repair_available !== false; - function readStatus(value: UpdateStatus) { + function readStatus(value: UpdateStatus, includeState = true) { setVersion(value.app_version); setRollbackAvailable(value.rollback_available === true); setRuntimeSelection(value.runtime_selection); - if (value.state?.phase) setState(value.state); + if (includeState && value.state?.phase) setState(value.state); + } + + function commitStatus(value: UpdateStatus, generation: number) { + if (generation !== statusGeneration.current) return false; + const stateIsCurrent = !busy.current || !value.state?.phase || workingPhases.includes(value.state.phase); + readStatus(value, stateIsCurrent); + return true; } useEffect(() => { @@ -56,22 +64,32 @@ export function DesktopUpdate() { useEffect(() => { if (!invoke) return; let alive = true; + const generation = ++statusGeneration.current; void invoke("desktop_update_status").then((value) => { - if (!alive) return; - readStatus(value); + if (!alive || !commitStatus(value, generation)) return; const selected = value.state?.details?.channel ?? (value.app_version.includes("-main.") ? "main" : "stable"); setChannel(selected); if (!value.state?.phase) { - void invoke("desktop_update", { action: "check", channel: selected }).then((next) => { if (alive) setState(next); }).catch((error: unknown) => { if (alive) setState(failedUpdate(error)); }); + const actionGeneration = ++statusGeneration.current; + void invoke("desktop_update", { action: "check", channel: selected }).then((next) => { + if (alive && actionGeneration === statusGeneration.current) setState(next); + }).catch((error: unknown) => { + if (alive && actionGeneration === statusGeneration.current) setState(failedUpdate(error)); + }); } - }).catch(() => { if (alive) setState(failedUpdate(null, "desktop_status_unavailable")); }); - return () => { alive = false; }; + }).catch(() => { + if (alive && generation === statusGeneration.current) setState(failedUpdate(null, "desktop_status_unavailable")); + }); + return () => { alive = false; statusGeneration.current++; }; }, [invoke]); useEffect(() => { if (!invoke || !working) return; const timer = window.setInterval(() => { - void invoke("desktop_update_status").then(readStatus).catch(() => {}); + const generation = ++statusGeneration.current; + void invoke("desktop_update_status").then((value) => { + commitStatus(value, generation); + }).catch(() => {}); }, 1000); return () => window.clearInterval(timer); }, [invoke, working]); @@ -79,14 +97,21 @@ export function DesktopUpdate() { async function run(action: "check" | "apply" | "repair" | "restart" | "rollback" | "forget_runtime_selection") { if (!invoke || busy.current) return; busy.current = true; + statusGeneration.current++; setState({ phase: action === "check" ? "checking" : action === "repair" ? "installing_runtime" : action === "forget_runtime_selection" ? "connecting" : "downloading" }); try { if (!version) { - readStatus(await invoke("desktop_update_status")); + const generation = ++statusGeneration.current; + commitStatus(await invoke("desktop_update_status"), generation); } - setState(await invoke("desktop_update", { action, channel })); + const next = await invoke("desktop_update", { action, channel }); + statusGeneration.current++; + setState(next); + } + catch (error: unknown) { + statusGeneration.current++; + setState(failedUpdate(error, version ? "update_failed" : "desktop_status_unavailable")); } - catch (error: unknown) { setState(failedUpdate(error, version ? "update_failed" : "desktop_status_unavailable")); } finally { busy.current = false; } } const message: Record = { diff --git a/examples/desktop-update-browser-smoke.mjs b/examples/desktop-update-browser-smoke.mjs index cff02ebd38..028a348862 100644 --- a/examples/desktop-update-browser-smoke.mjs +++ b/examples/desktop-update-browser-smoke.mjs @@ -23,12 +23,20 @@ const server = createServer(async (req, res) => { await new Promise((done) => server.listen(0, "127.0.0.1", done)); const origin = `http://127.0.0.1:${server.address().port}`; const browser = await launchBrowser(loadPlaywright().chromium); +function deferred() { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} try { await mkdir(output, { recursive: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const calls = []; let nativeState = null; let nativeActionSettled = Promise.resolve(); + let nativeActionDelayMs = 150; let nativeRuntimeSelection; let startupTiming = null; let failUpdate = false; @@ -36,10 +44,35 @@ try { let checkUpToDate = false; let statusFailure = false; let environmentTelemetry = null; + let delayedStatus = null; + function delayNextStatus(phase) { + delayedStatus = { + phase, + started: deferred(), + release: deferred(), + returned: deferred(), + }; + return delayedStatus; + } await page.exposeFunction("nativeInvoke", async (command, args) => { if (command === "desktop_update_status") { if (statusFailure) throw new Error("Command desktop_update_status not allowed by ACL"); - return { state: nativeState, runtime_selection: nativeRuntimeSelection, startup: startupTiming, app_version: "0.5.4", rollback_available: true, environment: environmentTelemetry }; + const result = { + state: structuredClone(nativeState), + runtime_selection: structuredClone(nativeRuntimeSelection), + startup: structuredClone(startupTiming), + app_version: "0.5.4", + rollback_available: true, + environment: structuredClone(environmentTelemetry), + }; + if (delayedStatus && delayedStatus.phase === result.state?.phase) { + const request = delayedStatus; + delayedStatus = null; + request.started.resolve(); + await request.release.promise; + request.returned.resolve(); + } + return result; } calls.push({ command, args }); if (checkFailure && args.action === "check") return { phase: "error", details: { code: checkFailure } }; @@ -48,7 +81,11 @@ try { return nativeState; } if (failUpdate) throw new Error("private diagnostic must not be displayed"); - nativeActionSettled = new Promise((done) => setTimeout(done, 150)).then(() => { + nativeState = { + phase: args.action === "check" ? "checking" : args.action === "repair" ? "installing_runtime" : ["align_runtime", "use_installed_runtime", "forget_runtime_selection"].includes(args.action) ? "connecting" : args.action === "rollback" ? "installing_app" : args.action === "restart" ? "restart_required" : "downloading", + details: { version: "0.5.5", channel: args.channel }, + }; + nativeActionSettled = new Promise((done) => setTimeout(done, nativeActionDelayMs)).then(() => { nativeState = { phase: args.action === "check" ? "available" : ["align_runtime", "use_installed_runtime", "forget_runtime_selection"].includes(args.action) ? "connecting" : "restart_required", details: { version: "0.5.5", channel: args.channel }, @@ -75,9 +112,19 @@ try { await page.getByRole("button", { name: "更新并准备重启", exact: true }).waitFor(); assert.deepEqual(calls.map((call) => call.args?.action), ["check"], "auto-check must not mutate installation"); await page.screenshot({ path: resolve(output, "update-panel.png") }); + nativeActionDelayMs = 1500; + const delayedWorkspaceStatus = delayNextStatus("downloading"); await page.getByRole("button", { name: "更新并准备重启", exact: true }).click(); + await delayedWorkspaceStatus.started.promise; + await nativeActionSettled; await page.locator(".personal-update-panel").getByRole("button", { name: "重启完成更新", exact: true }).waitFor(); + delayedWorkspaceStatus.release.resolve(); + await delayedWorkspaceStatus.returned.promise; + await page.waitForTimeout(50); + assert.equal(await page.getByRole("button", { name: "更新并准备重启", exact: true }).count(), 0, "a delayed workspace status must not replace restart_required"); + assert.equal(await page.locator(".personal-update-panel").getByRole("button", { name: "重启完成更新", exact: true }).count(), 1); assert.deepEqual(calls.map((call) => call.args?.action), ["check", "apply"]); + nativeActionDelayMs = 150; await page.reload(); await page.getByRole("button", { name: /重启完成更新/ }).waitFor(); assert.deepEqual(calls.map((call) => call.args?.action), ["check", "apply"], "reload must recover native progress, not repeat install"); @@ -234,6 +281,19 @@ try { await page.locator("#update").click(); await page.getByRole("button", { name: "更新并准备重启 / Install update" }).waitFor(); assert.deepEqual(calls.at(-1).args, { action: "check", channel: "main" }); + nativeActionDelayMs = 1500; + const applyCount = calls.filter((call) => call.args?.action === "apply").length; + const delayedBootStatus = delayNextStatus("downloading"); + await page.locator("#update").click(); + await delayedBootStatus.started.promise; + await nativeActionSettled; + await page.getByRole("button", { name: "重启完成更新 / Restart" }).waitFor(); + delayedBootStatus.release.resolve(); + await delayedBootStatus.returned.promise; + await page.waitForTimeout(50); + assert.equal(await page.locator("#update").innerText(), "重启完成更新 / Restart", "a delayed boot status must not replace restart_required"); + assert.equal(calls.filter((call) => call.args?.action === "apply").length, applyCount + 1, "the boot page must issue Apply once"); + nativeActionDelayMs = 150; await page.reload(); await page.getByText("恢复与更新 / Recovery & updates").click(); await page.waitForFunction(() => document.querySelector("#channel").value === "main");