[Bug]: .deb update fails before pkexec runs when the app was relaunched by a previous update#14905

openedOct 2, 2026byTTonybynMp4

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/desktop

Steps to reproduce

This has happened to me several times, always the same way:

  1. On a .deb install, let the app update itself once. The updater runs dpkg through pkexec and relaunches the app with app.relaunch().
  2. Leave that relaunched instance open until the next nightly (mine stayed up for 29 hours, mostly suspended).
  3. The update downloads. Click Restart.
  4. Click Restart again.

Expected behavior

The pkexec prompt appears and the update installs. If it fails, the first click shows an error that says why.

Actual behavior

The first click stops the backends and starts them again, and nothing tells you it failed. Later clicks show a toast with "Desktop updater install operation reported an error." No click ever installs the update.

The install never reaches the installer. During the three attempts the system journal has no pkexec, polkit or sudo entry, and dpkg.log has no entry for the new version. The previous update, run from a normally launched instance, logged all three. The backend restart after each failure succeeds, so spawning child processes still works in that process.

So electron-updater 6.8.9 rejects the install inside the app. BaseUpdater.install() or DebUpdater.doInstall() calls dispatchError before runCommandWithSudoIfNeeded runs. The candidates are "No update filepath provided", "Neither dpkg nor apt command found", or an exception in the command -v probes. I can't tell which, for two reasons:

  • handleUpdaterError logs only the fixed DesktopUpdaterReportedError message. That's #13977, which #14008 addresses with errorCode and exitStatus fields. Neither field would help here. Nothing ran, so there's no exit status, and these errors are plain Errors without a code.
  • electron-updater logs through console by default. The instance started by app.relaunch() wrote nothing to the journal in its whole lifetime, so its "Executing: ..." lines and stderr are lost too. A normally launched instance logs those lines under t3code.desktop.

The silent first click has a separate cause. quitAndInstall() doesn't throw when doInstall fails. It emits error, and our handler runs on another fiber. installDownloadedUpdate returns { accepted: true, completed: false, failed: false } before recovery writes a message, so getDesktopUpdateActionError finds no message and shows no toast. The next click reads the message the first recovery left behind.

Impact

Major degradation or frequent failure

Version or commit

0.0.45-nightly.20261001.2539 (.deb), updating to 0.0.45-nightly.20261002.2595

Environment

Ubuntu, GNOME on Wayland, electron-updater 6.8.9

Logs or stack traces

shell
1# desktop.trace.ndjson, run started by the previous update's relaunch (times CEST)
22026-10-02 23:00:44 INFO update available version: 0.0.45-nightly.20261002.2595
32026-10-02 23:00:52 INFO downloading update
42026-10-02 23:00:56 INFO update downloaded version: 0.0.45-nightly.20261002.2595
52026-10-02 23:01:42 ERROR Desktop updater install operation reported an error. operation: install
62026-10-02 23:01:47 INFO backend ready
72026-10-02 23:02:09 ERROR Desktop updater install operation reported an error. operation: install
82026-10-02 23:02:14 INFO backend ready
92026-10-02 23:02:17 ERROR Desktop updater install operation reported an error. operation: install
102026-10-02 23:02:18 INFO before-quit received (manual quit)
11
12# journalctl for the previous, successful update from a normally launched instance
13Oct 01 17:52:12 t3code.desktop[212533]: Running as non-root user, using sudo to install: pkexec,--disable-internal-agent
14Oct 01 17:52:15 pkexec[215875]: tony: Executing command [USER=root] ... dpkg -i .../T3-Code-0.0.45-nightly.20261001.2539-amd64.deb
15Oct 01 17:52:18 systemd[3557]: Started app-com.t3tools.T3Code-215954.scope. # relaunched instance, never writes to the journal

Workaround

Quit the app and launch it again from the desktop, then click Restart. The update installs from the normally launched instance. I've hit this several times and this worked every time.

Possible fix

I'd split this into two stacked PRs on top of #14008.

1. Logging and error handling.

  • Set autoUpdater.logger to the desktop-updater component logger, so electron-updater's own lines land in desktop.trace.ndjson whatever stdout points at. They have to go through the same redaction as the triage on #13977, because some of those lines include the feed URL.
  • Map electron-updater's known pre-install errors to a bounded reason (no-installer-path, no-package-manager, command-probe-failed). That keeps the no-message rule and still says which check failed.
  • Treat an error event that fires synchronously inside quitAndInstall as a failed install, and return failed: true with the message already in state. Then the first click shows the toast.

2. The actual failure. Once (1) reports which check fails in a relaunched instance, fix that check. Since a manual relaunch fixes it every time, the cause is almost certainly something about the process app.relaunch() starts after dpkg replaced the app, not uptime. The one confirmed difference so far is that its stdout no longer reaches the journal.

Note

Grok responding on behalf of Julius.

Triage

Thanks for such a careful report, @TonybynMp4! There are two separate failures here, and you split them correctly. First, the .deb install never reaches pkexec from an instance that app.relaunch() started. Second, the first Restart click doesn't say it failed. Quitting and relaunching from the desktop is a real workaround. This is also not the same as open #13977: that issue is about the generic log line, and this one is an install that never runs.

Missing journal lines. These come from Electron, not the updater. Desktop is on Electron 44.4.2. On Linux, app.relaunch() goes through relauncher_linux.cc, which starts a new process group and points the child's stdout and stderr at /dev/null. A normal desktop launch stays in the systemd user scope, so electron-updater's console lines (Running as non-root user, using sudo to install: pkexec,--disable-internal-agent, Executing: ...) show up under t3code.desktop. After a relaunch they don't. desktop.trace.ndjson still works because that logger writes to a file. That explains the log gap, but not which check rejected the install.

Which check fails. electron-updater 6.8.9 only dispatchErrors before runCommandWithSudoIfNeeded in two places, and both match "no pkexec, polkit, or sudo entry":

  • BaseUpdater.install, when installerPath or downloadedFileInfo is null: No update filepath provided, can't quit and install.
  • DebUpdater.doInstall, when both hasCommand("dpkg") and hasCommand("apt") are false: Neither dpkg nor apt command found. Cannot install .deb package.

hasCommand is spawnSyncLog("command", ["-v", cmd]), and it swallows every throw, including a non-zero command -v. So a probe exception isn't its own error; if both probes fail, you get the plain "Neither dpkg nor apt" Error. There's no third path before pkexec. These logs can't tell us which of the two it was.

Open PR #14008 won't classify either one. Both are plain Errors with no code and no Command … exited with code N text, so errorCode and exitStatus stay empty. An exit status only shows up if the install gets as far as spawning pkexec or sudo. The constraint from #13977 still applies: don't log error.cause, and don't hand electron-updater's logger to the desktop log unchanged, because download and install lines can include the feed URL. A bounded reason like no-installer-path or no-package-manager fits as the discriminator. The raw Executing: text doesn't.

Silent first click. This one is in our handler, not in quitAndInstall. DebUpdater.doInstall emits error and returns false. quitAndInstall doesn't throw, so ElectronUpdaterQuitAndInstallError never happens, and installDownloadedUpdate returns { accepted: true, completed: false }. DesktopUpdates.install drops the internal failed flag before IPC, since DesktopUpdateActionResult has no failed field, and the toast only reads state.message via getDesktopUpdateActionError. Meanwhile handleUpdaterError runs on another fiber, and recoverFailedInstall only writes the message after the backends restart. So the click returns while message is still null, and no toast shows. The next click is allowed because errorContext === "install", and it picks up the message the previous recovery left behind. That's why the toast shows up late and only says Desktop updater install operation reported an error. Returning failed: true alone won't fix the toast; the message has to be on state before install reads it.

Synced from GitHub

Found the root cause. The app being open for a long time has nothing to do with it. What matters is how the app was started.

I ran a diagnostic build that writes electron-updater's raw log to disk, waited for the next nightly, and clicked Restart twice. Both clicks reached the actual install:

1Executing: pkexec with args: --disable-internal-agent,/bin/bash,-c,'dpkg -i .../T3-Code-0.0.46-nightly.20261003.2632-amd64.deb'
2pkexec must be setuid root
3Command pkexec exited with code 127
4dpkg installation failed, trying to fix broken dependencies with apt-get
5...
6pkexec must be setuid root
7Error: Command pkexec exited with code 127

dpkg, apt and pkexec were all on PATH and the downloaded .deb was there. /usr/bin/pkexec is -rwsr-xr-x root on a normal mount. The problem is the app process itself, which has NoNewPrivs: 1 in /proc/<pid>/status. With that flag set, the kernel ignores the setuid bit on anything the process runs, so pkexec can never become root. Once a process has the flag, it can't clear it.

I checked the flag for both ways the app gets started:

How the app was startedNoNewPrivs
From the desktop launcher0
By app.relaunch(), which covers the Settings relaunch and electron-updater's relaunch after an install1

Chromium's process launcher appears to set PR_SET_NO_NEW_PRIVS on the relaunched process. So whenever a .deb update succeeds and relaunches the app, the next update from that instance is guaranteed to fail. My guess is that's why it looked intermittent and seemed to depend on time.

To get past it now, quit T3 Code, start it from the launcher, then click Restart. Running sudo dpkg -i on the file in ~/.cache/t3code-updater/pending/ also works.

Updated plan:

  1. PR 1, logging. Give this failure its own bounded reason: "no-new-privs" and report it on the first click.
  2. PR 2, the fix. Run the privileged install outside the app's process tree, for example with systemd-run --user. The user manager starts that command without the flag. When that isn't available, tell the user to restart T3 Code from the launcher instead of showing the generic error.
Synced from GitHub

Note

Claude Opus 5.5 (Claude Code 2.1.311) responding on behalf of MohawkVader, from a t3 triage session.

Confirmed on Linux arm64, so it isn't specific to amd64.

Environment: T3 Code (Nightly) desktop .deb on an NVIDIA DGX Spark–class GB10 system: DGX OS 7.3.1, which is Ubuntu 24.04.5 LTS, kernel 7.0.0-1019-nvidia, GNOME on X11. Updated 0.1.3-nightly.20261027.3754 → 0.1.4-nightly.20261027.3770 while watching.

Observed during one update:

  • The updating instance had been opened from the app menu, with NoNewPrivs: 0. Its quitAndInstall ran pkexec --disable-internal-agent /bin/bash -c 'dpkg -i …/T3-Code-0.1.4-nightly.20261027.3770-arm64.deb', and the install succeeded.
  • The instance started by isForceRunAfter / app.relaunch() came back with NoNewPrivs: 1 on the main process. It runs in a new app-com.t3tools.T3Code-<pid>.scope with parent systemd --user (not the app-gnome-t3code-*.scope from the menu launch).
  • /usr/bin/pkexec is setuid root (-rwsr-xr-x), so the next update from that instance can't escalate. This matches the root cause above. Fully quitting and opening from the menu clears the flag and the next update installs.

Side note: a password prompt appears even though this user has passwordless sudo. electron-updater tries pkexec before sudo, and polkit doesn't honor sudoers. A local polkit rule allowing only the updater's dpkg -i <pending T3 .deb> and its apt-get install -f -y fallback removes the prompt. That install went through with no prompt. It might be worth noting for Linux users, or having the updater prefer sudo -n when it succeeds.

Synced from GitHub

Note

Claude Opus 5.5 (Claude Code) responding on behalf of zakius, from a t3 triage session.

Confirmed on KDE Plasma (Wayland), PikaOS (Debian-based), kernel 7.2.4, amd64, using the .deb nightly, current version 0.1.4-nightly.20261028.3814.

It failed three times in a row, each time in the instance started by the previous update's app.relaunch():

Failed installThat instance started atStarted by
10-26 15:5110-21 13:46:02relaunch after the 13:46:01 update
10-27 11:0110-26 15:55:08relaunch after the 15:55:08 update
10-28 10:3810-27 15:06:16relaunch after the 15:06:16 update

Each time, ElectronUpdater.quitAndInstall returned in about 60 ms, and then handleUpdaterError logged cause: "Error: Command pkexec exited with code 127". The journal has no lines from the app, pkexec or polkit for those attempts. The UI stays on "Restarting" until the user quits the app.

After a manual launch, NoNewPrivs: 0. The same click takes 7–10 s while polkit-kde-authentication-agent-1 shows its dialog, and then dpkg -i succeeds. So this matches the NoNewPrivs root cause, and it doesn't depend on the desktop environment. The latest nightly is still affected.

Synced from GitHub

Confirming on a newer nightly, same setup (Ubuntu, GNOME on Wayland, .deb). Updating 0.1.4-nightly.20261028.3841 → 0.1.5-nightly.20261029.3873.

What happened The instance that the 3824 update had relaunched failed to install 3873. There was no password prompt, the UI showed "Reconnecting…", and nothing installed. After I quit and opened it again from GNOME, the install prompted for my password and worked. Across three updates, every instance I launched myself installed fine and the one relaunched by the updater failed.

Evidence

  • In desktop.trace.ndjson for the failing run, handleUpdaterError logged cause: "Error: Command pkexec exited with code 127", operation: install, and then recoverFailedInstall ran. The journal has no polkit entries for that attempt. The successful installs logged polkit-agent-helper and pkexec: … Executing command [USER=root].
  • The instance running now was also relaunched by the updater, and /proc/<main pid>/status shows NoNewPrivs: 1. gnome-shell shows NoNewPrivs: 0.
  • Running pkexec --disable-internal-agent /bin/true from a process that inherits the flag prints pkexec must be setuid root and exits 127.
  • pkcheck --action-id org.freedesktop.policykit.exec --process <pid> returns auth_admin for both the relaunched app and gnome-shell. Polkit and the session are fine; the setuid bit is just never applied.

This matches the root cause in the earlier comment and the fix in #15387. Once #15387 ships in a nightly, I can test two consecutive packaged .deb updates, which the PR says still need a human to check.

Diagnosed and written by Claude Opus 5.5 through Claude Code in T3 Code.

Synced from GitHub

Note

Claude Opus 5.5 (Claude Code) responding on behalf of abulhuman, from a t3 triage session.

This is another way to hit the silent first click, with a different root cause from NoNewPrivs. It reaches the second dispatchError path that the triage comment listed, Neither dpkg nor apt command found.

Environment: Omarchy (Arch Linux), kernel 7.2.8, Hyprland on Wayland, x64. The desktop app is 0.1.5-nightly.20261029.3877. It was installed by unpacking the nightly .deb by hand into a user directory, since Arch has no dpkg. The update went to 0.1.6-nightly.20261030.3904.

What happened: The update downloaded. I clicked Restart and confirmed. The app didn't restart, no error showed, and the button went back to "downloaded, click to restart and install".

Trace (desktop.trace.ndjson, local time):

105:55:20 INFO update downloaded version: 0.1.6-nightly.20261030.3904
205:55:30 desktop.ipc.updates.install -> installDownloadedUpdate
305:55:33 ElectronUpdater.quitAndInstall exit: Success
405:55:33 desktop.updates.recoverFailedInstall
505:55:33 ERROR Desktop updater install operation reported an error.
6 errorTag: DesktopUpdaterReportedError, operation: install
7 cause: "Error: Neither dpkg nor apt command found. Cannot install .deb package."

Why it happens: resources/package-type survives when someone unpacks the .deb by hand, so the updater decides this is a .deb install. getAutoUpdateDisabledReason in apps/desktop/src/updates/DesktopUpdates.ts (tag v0.1.5-nightly.20261029.3877, unchanged on main) then sees isDebPackage as true and leaves updates on. Every install then fails inside DebUpdater.doInstall. This hits anyone on Arch, Fedora, or any other distro without dpkg who runs the .deb payload, which is likely what most hand-rolled and AUR-style repackages do.

Separate UI problem, not covered by #15387: reduceDesktopUpdateStateOnInstallFailure (updateMachine.ts) sets status: "downloaded" with errorContext: "install". getDesktopUpdateButtonTooltip (apps/web/src/components/desktopUpdate.logic.ts) only shows Install failed for X. Click to retry. when status === "error". So even after recovery stores the message, the tooltip still says "downloaded, click to restart and install". #15387 fixes the missing toast on the first click but doesn't change this tooltip.

Possible fixes:

  • When package-type is deb, also check that dpkg or apt exists before enabling updates. If neither exists, return a disabled reason such as "This install can't update itself. Use the AppImage for automatic updates on this distro."
  • Make the tooltip show the install failure when errorContext === "install" and status is downloaded.

Workaround: Switch to the nightly x86_64.AppImage and save it under a name with no version in it. electron-updater then replaces it in place, so the launcher path stays valid. The AppImage uses the static type2 runtime, so it runs with only fuse3 installed. I confirmed that it launches on 0.1.6-nightly.20261030.3904 and that its update check runs. No newer nightly has come out since, so I haven't tested a full update yet.

Synced from GitHub

Another symptom: snap-packaged CLIs can't run either, so T3 reports a snap-installed glab as not installed. snap-confine gets its permissions from file capabilities, and the kernel ignores those under no_new_privs:

1snap-confine is packaged without necessary permissions and cannot continue
2required permitted capability cap_dac_override not found in current capabilities
Synced from GitHub

Note

Claude Opus 5.5 (Claude Code) responding on behalf of delor, from a t3 triage session.

Confirmed on the stable channel too. The .deb update from the 0.1.6 release to the 0.1.7 release (channel latest) fails the same way.

Environment: Kubuntu 26.04, KDE Plasma 6 on Wayland, kernel 7.0, amd64. T3 Code (Beta) desktop .deb 0.1.6, Electron 44.10.0.

Time (CET)Event
11-02 10:17T3 Code 0.1.5 started from Plasma at login
11-02 15:01:39The updater runs pkexec … dpkg -i …0.1.6…. The polkit dialog appears and the install succeeds.
11-02 15:01:45The instance started by app.relaunch() comes up in app-com.t3tools.T3Code-<pid>.scope. Its main process has NoNewPrivs: 1.
11-05 10:42–11:35Five Restart clicks for 0.1.7. Each one logs cause: "Error: Command pkexec exited with code 127", and the journal has no polkit or pkexec entry.

Running pkexec --disable-internal-agent /bin/true inside that instance prints pkexec must be setuid root and exits 127. The 0.1.7 .deb in the updater cache matches its sha512, so the download is fine.

Two effects not mentioned above:

  • Each failed click restarts the backend, which ends any agent session running in it. One of those clicks cut off this triage session.
  • kubectl installed as a snap also fails inside the relaunched instance. It hits the same snap-confine … cap_dac_override error as the glab case.

The flag is set when the relauncher helper starts. In Electron v44.10.0, shell/browser/relauncher.cc launches the helper with default base::LaunchOptions, so Chromium's launch_posix.cc sets PR_SET_NO_NEW_PRIVS on it. relauncher_linux.cc sets allow_new_privs = true only for the final launch, and by then the flag has already been inherited. This agrees with electron/electron#41463 and with #15387, which stops using app.relaunch() on Linux.