Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion apps/bootstrap-installer/src-tauri/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed(
let mut rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
Expand All @@ -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) {
Expand Down Expand Up @@ -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<i32>) -> bool {
exit_code != Some(0)
}

/// Spawn `hermes <args>` 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).
Expand Down Expand Up @@ -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!(
Expand Down
13 changes: 9 additions & 4 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/electron/update-rebuild.cjs
Original file line number Diff line number Diff line change
@@ -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 }
55 changes: 55 additions & 0 deletions apps/desktop/electron/update-rebuild.test.cjs
Original file line number Diff line number Diff line change
@@ -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')
})
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading