Skip to content

fix(electron): install Linux .deb updates without freezing the app - #416

Open
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:fix/linux-deb-update-blocking-install
Open

fix(electron): install Linux .deb updates without freezing the app#416
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:fix/linux-deb-update-blocking-install

Conversation

@audichuang

Copy link
Copy Markdown
Contributor

Related issue

Closes #415

Problem / pressure

DebUpdater.doInstall runs its package manager through
BaseUpdater.spawnSyncLog, which uses spawnSync. That blocks the Electron
main process, event loop included, until the command exits, and on Linux the
command is pkexec, which does not exit until the polkit password prompt is
answered. Nothing on the JS side can interrupt a spawnSync already in
progress, so there is no timeout and no cancel.

On the reporter's machine one click froze the app for 51 minutes, waiting on a
prompt that was on a desktop session nobody was looking at. A window that stops
repainting that long is indistinguishable from a crash, and the app can neither
report nor cancel anything while it is in that state.

Separately, a failed install discarded a package that was still installable.
phase: 'error' makes readUpdateBannerState return null (sidebar banner
gone), makes about-setting.tsx swap the install button for "Check for
updates", and makes quitAndInstall answer update_not_downloaded for a file
sitting in the pending cache. A transient dpkg lock therefore cost the reporter
the retry until the next 30-minute check; they recovered only by finding the
manual re-check button.

Summary

  • New app-updater-linux-install.ts resolves the privileged command for a
    downloaded .deb and runs it asynchronously, resolving on the installer's
    exit rather than blocking the loop.
  • AppUpdaterService.quitAndInstall is now async. When the plan resolves it
    spawns pkexec --disable-internal-agent dpkg -i <file> and calls
    app.relaunch() / app.quit() only once the installer has exited 0;
    otherwise it reports the exit code. Every other target keeps electron-updater
    unchanged.
  • Argv is passed directly, so the shell quoting the sync path needed is gone,
    the apt-get install -f -y retry that raised a second password prompt for the
    same install is gone, and a signalled installer is a failure rather than the
    success spawnSync reports for status: null.
  • installInFlight stops a second click from raising a second prompt now that
    the window stays interactive during the first.
  • recordError keeps phase: 'downloaded' while a package is on disk, so a
    failed install or a failed periodic check no longer hides the retry. It
    replaces the two places that wrote phase: 'error' directly, and
    quitAndInstall detects an electron-updater install failure from an error
    count instead of from the phase.
  • The About panel shows a destructive status line when a downloaded update
    carries an error, so the surface that keeps its button also says why the last
    attempt failed.

Before / after

Before After
Main process blocked in spawnSync for the whole polkit prompt; app frozen, no cancel, no timeout spawn + await on exit; the window keeps repainting and responding while the prompt is open
app.relaunch() ran whenever doInstall returned true, including for a signal-killed installer (status: null) Relaunch only on exit code 0; signals, 126, 127 and any dpkg code are reported
dpkg -i failure retried through apt-get install -f -y, raising a second password prompt Single privileged command; the dpkg error is reported as-is
A failed install or a network blip during a check set phase: 'error', hiding the sidebar banner and the install button for up to 30 minutes The package stays downloaded with its error attached; both surfaces keep the retry
About panel silently stopped its spinner on failure About panel shows the failure next to the button it now keeps

Test plan

  • apps/electron/src/main/services/app-updater-linux-install.test.mjs (new, 12
    cases, added to the package test script): plan resolution for deb vs
    AppImage vs non-Linux, a package path with spaces passed as one unescaped
    argument, and through an injected spawn — that the call is still unsettled
    after the spawn returns and settles only on exit, exit 0, 126, 127, a
    non-zero dpkg code with its stderr, a signalled installer, an error event,
    a spawn that throws, and error plus exit settling once.
  • pnpm --filter @lody/electron run test — 90/90 pass excluding
    loro-data-plane-relay.test.mjs, which fails in this checkout with "Electron
    failed to install correctly": node_modules/electron/dist/electron is absent
    here, unrelated to this change.
  • pnpm --filter @lody/electron run typecheck, pnpm --filter @lody/components run typecheck, pnpm lint (0 errors), pnpm lint:i18n,
    pnpm check:public-boundary, pnpm check:platform-boundaries,
    pnpm check:code-collab-imports — all pass.
  • Full pnpm typecheck cannot complete in this checkout: prepare:acp-adapters
    fails building the acp-extension-dsh submodule on a ReadableStream type
    assertion. Verified identical on a clean tree with these changes stashed, so
    it is pre-existing and unrelated.
  • Not verified: an end-to-end packaged .deb upgrade. It needs a signed release
    pair on the update feed, which this checkout cannot produce.

Context handoff

Instructions for reviewing agents

  • Review focus: app-updater-service.ts quitAndInstall / installLinuxDeb
    for the quit-only-on-success ordering, and recordError plus the
    update-available / update-downloaded listeners for downloadedFile
    lifetime.
  • Decisions to challenge: dropping the apt-get install -f -y fallback;
    reporting instead of falling back when pkexec is unavailable; keeping
    phase: 'downloaded' for every error while a package is on disk.
  • Plausible failures / evidence gaps: no end-to-end packaged upgrade was
    run; the promise settles on the installer's exit, so a helper that never
    exits (observed once on the reporter's machine after a successful dpkg,
    undiagnosed) leaves the app responsive but not relaunched with a stuck
    spinner; update-available clears downloadedFile and update-downloaded
    re-sets it from cache each cycle, leaving a sub-second window where the plan
    does not resolve.

Authoring context

  • User goal / directives: Fix the Linux .deb in-app update at the root
    after a previous PR addressed only failure reporting, then file the issue and
    this PR.
  • Constraints / non-goals: No dependency changes, no new IPC surface, no
    behavior change for Windows, macOS, AppImage, or Sparkle; the upstream
    spawnSync in electron-updater is bypassed, not patched.
  • Risk-bearing decisions: Lody now owns quitting and relaunching for .deb
    installs, so a wrong exit-code reading would either skip a restart or restart
    without an install; both are reported to the renderer rather than silent.
  • Destructive or irreversible behavior: dpkg -i on the downloaded package
    is unchanged from the previous path. Dropping the apt-get -f retry means an
    unmet-dependency install now stops and reports rather than attempting repair;
    recovery is sudo apt-get install -f and the error text says so.
  • Deliberately not done or tested: rpm and pacman are not shipped and keep
    electron-updater; the mobile About surface is untouched because the bug is
    Linux-desktop only; no packaged end-to-end upgrade was run.
  • Unknowns / confidence: High for the async install and the retained-package
    behavior, both covered by unit tests and read against electron-updater 6.7.3
    sources. Lower for exotic Linux sessions without a polkit agent, which now get
    a reported failure instead of a freeze but still cannot install in place.

electron-updater's DebUpdater shells out through BaseUpdater.spawnSyncLog,
and spawnSync blocks the whole main process, event loop included, for as long
as polkit keeps its password prompt open. When that prompt is not answered
(a dialog behind other windows, an SSH session, no graphical agent) the app
hangs with no window updates and no way to cancel, and nothing on the JS side
can interrupt a spawnSync that has already started.

Run the privileged install here instead: spawn pkexec asynchronously, quit
only after a zero exit, and report the exit code. Only a downloaded .deb
takes this path. AppImage rewrites its own file as the current user, and
every other platform keeps electron-updater. Passing argv directly also drops
the shell quoting the sync path needed, skips the apt-get retry that raised a
second password prompt for the same install, and treats a signalled installer
as a failure rather than the success spawnSync reports for `status: null`.

A failed install or a failed periodic check no longer discards a downloaded
package. recordError keeps the phase at `downloaded` while the package is on
disk, because dropping to `error` hid both the sidebar banner and the About
install button, which are the only ways to retry: a transient dpkg lock left
the user with no button and an `update_not_downloaded` reply for a package
sitting in the cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Model: claude-opus-5[1m]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Linux .deb in-app update freezes the app while pkexec runs, and a failed install hides the retry

1 participant