From 4b7a186003934590a1f77c2be5bf5caeae0c2ffe Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 17 Jun 2026 19:33:27 -0500 Subject: [PATCH] fix(desktop): retry the self-update rebuild once so the app relaunches (#48122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop self-update runs `hermes update` then `hermes desktop --build-only`, and only relaunches if the rebuild returns 0. The first `--build-only` can exit nonzero on a still-settling post-update tree or a network-blocked Electron fetch that the installer's self-heal repaired mid-run — so both updaters (the Tauri setup binary and the in-app POSIX path) bailed before the relaunch step. The update landed but the app never restarted; a manual launch worked because the heal had completed. Retry `--build-only` once in both paths before failing, mirroring the retry-once `hermes update` already does (and the CLI `hermes update`'s own desktop rebuild). A second run builds clean off the healed dist and is a near-no-op when the first actually succeeded (content-hash stamp). - update.rs: retry stage 2; add rebuild_needs_retry() + test - main.cjs: retry via new update-rebuild.cjs helper (behavior-tested) --- .../src-tauri/src/update.rs | 47 +++++++++++++++- apps/desktop/electron/main.cjs | 13 +++-- apps/desktop/electron/update-rebuild.cjs | 29 ++++++++++ apps/desktop/electron/update-rebuild.test.cjs | 55 +++++++++++++++++++ apps/desktop/package.json | 2 +- 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/electron/update-rebuild.cjs create mode 100644 apps/desktop/electron/update-rebuild.test.cjs diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 40d136f960d..a42838293a1 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> { emit_stage(&app, "rebuild", StageState::Running, None, None); let started = Instant::now(); let rebuild_args: Vec = vec!["desktop".into(), "--build-only".into()]; - let rebuild = run_streamed( + let mut rebuild = run_streamed( &app, &hermes, &rebuild_args, @@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> { Some("rebuild"), ) .await?; + + // Retry-once: the first `--build-only` can return nonzero on a still-settling + // post-update tree or a network-blocked Electron fetch that our self-heal + // repaired mid-run. A second attempt then builds clean off the healed dist + // (the content-hash stamp makes it a near-no-op when the first actually + // succeeded). Without this the updater bails here and never reaches the + // relaunch below — the app updates but doesn't restart. Matches the + // retry-once `hermes update` already does above, and `hermes update`'s own + // desktop rebuild in cmd_update. + if rebuild_needs_retry(rebuild.exit_code) { + emit_log( + &app, + Some("rebuild"), + LogStream::Stdout, + "[rebuild] first desktop rebuild failed; retrying once (a self-healed \ + Electron download builds clean on the second run)…", + ); + rebuild = run_streamed( + &app, + &hermes, + &rebuild_args, + &install_root, + &child_env, + Some("rebuild"), + ) + .await?; + } let rebuild_ms = started.elapsed().as_millis() as u64; if rebuild.exit_code != Some(0) { @@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool { } } +/// Whether the `desktop --build-only` rebuild should be retried once. Any +/// non-success exit qualifies: the common cause is a transient first-attempt +/// failure (still-settling tree / self-healed Electron download) that a clean +/// second run resolves. +fn rebuild_needs_retry(exit_code: Option) -> bool { + exit_code != Some(0) +} + /// Spawn `hermes ` from `cwd`, stream stdout/stderr as Log events on the /// bootstrap channel, and return the exit code. Mirrors powershell::run_script /// but for an arbitrary command (no install.ps1 -File wrapping). @@ -970,6 +1005,16 @@ mod tests { assert_eq!(update_branch_from_args(["--update"]), None); } + #[test] + fn rebuild_retries_only_on_failure() { + assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry"); + assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once"); + assert!( + rebuild_needs_retry(None), + "a killed/signalled rebuild (no exit code) retries once" + ); + } + #[test] fn parses_only_app_targets() { assert_eq!( diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c71afae7cb8..c8e31becf6e 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -45,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') +const { runRebuildWithRetry } = require('./update-rebuild.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -2009,10 +2010,14 @@ async function applyUpdatesPosixInApp() { } emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) - const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], { - cwd: updateRoot, - env, - stage: 'rebuild' + // Retry-once: a first rebuild can fail on a still-settling tree or a + // self-healed (network-blocked) Electron download; a second run builds clean + // off the healed dist so we reach the swap+relaunch below instead of bailing. + const rebuilt = await runRebuildWithRetry(attempt => { + if (attempt > 0) { + emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) + } + return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) }) if (rebuilt.code !== 0) { emitUpdateProgress({ diff --git a/apps/desktop/electron/update-rebuild.cjs b/apps/desktop/electron/update-rebuild.cjs new file mode 100644 index 00000000000..ec8a948316d --- /dev/null +++ b/apps/desktop/electron/update-rebuild.cjs @@ -0,0 +1,29 @@ +'use strict' + +/** + * Retry-once policy for the desktop `--build-only` rebuild during self-update. + * + * The first rebuild can return nonzero on a still-settling post-update tree or a + * network-blocked Electron fetch that the installer's self-heal repaired mid-run. + * A second attempt then builds clean off the healed dist (the content-hash stamp + * makes it a near-no-op when the first actually succeeded). Without the retry the + * updater bails before the relaunch step — the app updates but doesn't restart. + */ + +function shouldRetryRebuild(code) { + return code !== 0 +} + +/** + * Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure. + * Returns the final result. + */ +async function runRebuildWithRetry(rebuild) { + let result = await rebuild(0) + if (shouldRetryRebuild(result.code)) { + result = await rebuild(1) + } + return result +} + +module.exports = { shouldRetryRebuild, runRebuildWithRetry } diff --git a/apps/desktop/electron/update-rebuild.test.cjs b/apps/desktop/electron/update-rebuild.test.cjs new file mode 100644 index 00000000000..623effa4d13 --- /dev/null +++ b/apps/desktop/electron/update-rebuild.test.cjs @@ -0,0 +1,55 @@ +/** + * Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop + * `--build-only` rebuild during self-update. + * + * Run with: node --test electron/update-rebuild.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * Why this matters: a first rebuild can return nonzero on a still-settling tree + * or a self-healed (network-blocked) Electron download. Without a second attempt + * the updater bails before the relaunch step — the app updates but never restarts + * (the field report behind this fix). The retry must fire on failure, not on + * success, and must run at most twice. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') + +test('shouldRetryRebuild retries only on a non-success exit', () => { + assert.equal(shouldRetryRebuild(0), false) + assert.equal(shouldRetryRebuild(1), true) + assert.equal(shouldRetryRebuild(null), true) +}) + +test('a clean first rebuild runs once and does not retry', async () => { + const codes = [] + const result = await runRebuildWithRetry(attempt => { + codes.push(attempt) + return Promise.resolve({ code: 0 }) + }) + assert.deepEqual(codes, [0]) + assert.equal(result.code, 0) +}) + +test('a failed first rebuild retries once and succeeds', async () => { + const codes = [] + const result = await runRebuildWithRetry(attempt => { + codes.push(attempt) + return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) + }) + assert.deepEqual(codes, [0, 1]) + assert.equal(result.code, 0) +}) + +test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { + const codes = [] + const result = await runRebuildWithRetry(attempt => { + codes.push(attempt) + return Promise.resolve({ code: 1, error: 'rebuild-failed' }) + }) + assert.deepEqual(codes, [0, 1]) + assert.equal(result.code, 1) + assert.equal(result.error, 'rebuild-failed') +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cff1877a1ba..70d35fb7bb0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",