Skip to content

(updater): typing the admin password for a .deb update is interrupted by a not-responding dialog #475

Description

@jbr-sekoia

Symptom

On Ubuntu with the .deb install, Restart to update opens the system authentication dialog for the admin password. While the password is being typed, GNOME shows "Switchboard is not responding" over it and the focus jumps to that dialog, repeatedly. Once the password is accepted the update goes through and the app recovers.

Cause

  • updater-install (main.js:3104-3109) calls autoUpdater.quitAndInstall() with the windows still mapped.
  • On a .deb, electron-updater 6.8.9 installs before quitting. BaseUpdater.quitAndInstall runs install(), then app.quit(). DebUpdater.doInstall runs dpkg -i through LinuxUpdater.runCommandWithSudoIfNeeded, which wraps it in pkexec (the first of gksudo, kdesudo, pkexec, beesu found) via spawnSyncLog, a synchronous spawn (node_modules/electron-updater/out/LinuxUpdater.js:34-51, DebUpdater.js).
  • The main process is therefore blocked for as long as the polkit dialog is open. Its windows stop answering the compositor's ping, and mutter raises the "not responding" dialog, which takes the focus.

Wanted

Typing the password is not interrupted: no "not responding" dialog while the authentication prompt is open, and the update then completes and relaunches as today.

Directions to weigh in the specification

  1. Unmap or destroy every window before calling quitAndInstall on Linux. The blocked process then has no window to ping. This is the smallest change. Measure whether a hidden window is still pinged.
  2. Install outside the blocked process. On quit, start a detached helper that runs pkexec dpkg -i <file> and relaunches the app; then the app exits at once. The app is gone while the password is typed, at the cost of owning the install step that electron-updater does today.

What must hold either way:

  • The unsaved-edits guard (unsavedGuard.confirmQuit) still runs before anything is hidden.
  • The ActivityWatch flush still runs.
  • A cancelled authentication (pkexec exit 126) leaves the app running with its windows back. Today electron-updater dispatches an error in that case and the app stays open.
  • AppImage, Windows and macOS updates are unchanged.

Activity

  1. jbr-sekoia commented on Oct 5, 2026

    @jbr-sekoia
    CollaboratorAuthor

    Spec — #475: a .deb update does not block the main process while the password is asked

    • Repository: /home/jean-baptiste-renard/workspace/tools/switchboard (fork devsuitup/switchboard) VERIFIED
    • origin/main: 42a931d3827c2b8793af5d9942d8ceee37b6b1be (git fetch; git rev-parse origin/main; (touched): open markdown formatted, with a remembered toggle to the source #476, (touched): open every clicked file in Touched, with markdown formatted by default #472 round A). The claims were first measured at 375decbf8c14543b7f8ee7783da85618116cc7de. git diff --stat 375decb 42a931d touches 9 files, none of them main.js, unsaved-guard.js, preload.js, public/app.js, package-lock.json or node_modules. So every main.js, guard, preload, app.js and electron-updater citation holds at 42a931d. The public/file-panel.js lines are cited at round B (f76c680, which contains 42a931d), and .ai/contexts/viewer-panel.md:170 is unchanged. VERIFIED
    • Read at: 2026-10-05T11:54:48+02:00; re-checked at 2026-10-05T14:18:57+02:00 VERIFIED
    • Versions: electron-updater 6.8.9 (node_modules/electron-updater/package.json), Electron 41.0.3 (package-lock.json:4424) VERIFIED
    • Measurement host: GNOME Shell 50.1, XDG_SESSION_TYPE=wayland, org.gnome.mutter check-alive-timeout = 5000 ms, switchboard 0.0.89 installed as a deb, /opt/Switchboard/resources/package-type = deb VERIFIED

    1 — Claims of the issue, measured at HEAD

    # Claim Status Evidence
    1 updater-install is main.js:3104-3109 and calls quitAndInstall() with the windows mapped STILL PRESENT main.js:3104 opens the handler, :3106 confirmQuit, :3107 sets activityFlushedForQuit = true, :3108 autoUpdater.quitAndInstall(); no hide/destroy anywhere in it. VERIFIED
    2 BaseUpdater.quitAndInstall runs install() then app.quit() STILL PRESENT BaseUpdater.js:13-27: install() is called synchronously at :16; only when it returns true does setImmediate (:18) emit before-quit-for-update and call this.app.quit() (:21). On false it resets quitAndInstallCalled and does not quit. VERIFIED
    3 DebUpdater.doInstall runs dpkg -i through runCommandWithSudoIfNeeded, wrapped in the first of gksudo, kdesudo, pkexec, beesu STILL PRESENT DebUpdater.js:40 → installWithCommandRunner → commandRunner(["dpkg","-i",path]); LinuxUpdater.js:34-51 + determineSudoCommand (list ["gksudo","kdesudo","pkexec","beesu"], default sudo); pkexec gets --disable-internal-agent. VERIFIED
    4 The spawn is synchronous (spawnSyncLog) STILL PRESENT BaseUpdater.js:102-121: child_process.spawnSync(cmd, args, {shell: true, ...}). VERIFIED
    5 The main process is blocked while the polkit dialog is open STILL PRESENT ~/.config/switchboard/logs/main.log: 09:38:14.789 Executing: pkexec ... 'dpkg -i .../pending/switchboard_0.0.89_amd64.deb', next line 09:38:34.237 — 19.4 s with no log line from a process that otherwise logs continuously, against mutter's 5 s check-alive timeout. VERIFIED
    6 Its windows stop answering the compositor's ping and mutter raises "not responding" NOT VERIFIABLE here Requires the compositor and a human typing; the browser-process UI thread answering the ping is the one blocked by spawnSync (Electron runs JS main and the UI thread on one thread). ASSUMED
    7 A cancelled authentication (pkexec 126) leaves the app running; electron-updater dispatches an error STILL PRESENT, incomplete A dpkg failure of any cause, a 126 included, is caught at DebUpdater.js:57-62 and followed by commandRunner(["apt-get","install","-f","-y"]) — a second pkexec prompt. Only when that one fails too does doInstall dispatch the error and return false (DebUpdater.js:41-44). Simulated: a runner that throws exited with code 126 is called twice, dpkg -i /tmp/x.deb then apt-get install -f -y, then rethrows. VERIFIED
    8 "The ActivityWatch flush still runs" (as a property to keep) REFUTED for the install path updater-install sets activityFlushedForQuit = true (main.js:3107) so before-quit (main.js:3317) never holds for an install; .ai/contexts/activitywatch.md:217 "An update install is never held"; test/activitywatch-wiring.test.js:66-70 pins it. activityReporter.stop() still runs (main.js:3324). VERIFIED
    9 "The unsaved-edits guard runs before anything is hidden" STILL PRESENT main.js:3106 awaits unsavedGuard.confirmQuit(mainWindow) before quitAndInstall. VERIFIED
    10 AppImage, Windows, macOS are distinct updaters STILL PRESENT electron-updater/out/main.js:42-77: NsisUpdater on win32, MacUpdater on darwin, otherwise AppImageUpdater replaced by DebUpdater/RpmUpdater/PacmanUpdater when process.resourcesPath/package-type reads deb/rpm/pacman. VERIFIED

    Findings the issue does not state:

    • After a cancelled install, unsavedGuard stays approved: confirmQuit sets quitApproved = true (unsaved-guard.js:113-117, assignment at :115) and nothing resets it, so every later quit or window close skips the unsaved-edits question (unsaved-guard.js:61, :84). VERIFIED
    • The autoInstallOnAppQuit path (normal quit with a downloaded update): addQuitHandler registers app.once('quit') (BaseUpdater.js:69-90, ElectronAppAdapter.js:37-39); it calls install(true, false) — same synchronous pkexec dpkg -i, no relaunch. quit is emitted after will-quit, which Electron documents as "Emitted when all windows have been closed" (node_modules/electron/electron.d.ts:953). So the process blocks the same way, but with no window left to ping: the issue's symptom cannot appear there; a password prompt appears after the window has gone, and a cancel costs two prompts (finding of row 7). VERIFIED (ordering from the Electron typings; the absence of the dialog without windows is ASSUMED)
    • PacmanUpdater.doInstall (PacmanUpdater.js:27-44) has the same synchronous pkexec shape; pacman is a shipped target (package.json build.linux.target = AppImage, deb, pacman). VERIFIED
    • Every update-downloaded event carries downloadedFile (AppUpdater.js:592-595, typed in types.d.ts:35), also for a cached download (AppUpdater.js:604-607 goes through the same done). This is the public way to get the file; installerPath/downloadedUpdateHelper are protected (BaseUpdater.d.ts:11, AppUpdater.d.ts:100). VERIFIED
    • DebUpdater is exported (electron-updater/out/main.js:29-30); instanceof separates the classes: AppImageUpdater→false, PacmanUpdater→false, DebUpdater→true (node one-liner run at HEAD). LinuxUpdater is not exported. VERIFIED
    • pkexec(1) RETURN VALUE: 126 when the user dismissed the dialog, 127 when not authorized / authorization could not be obtained / error, otherwise the program's status (man pkexec). VERIFIED

    Decision: neither direction as written — install asynchronously, in-process

    The code shows a third direction: on a DebUpdater, updater-install runs pkexec itself with an asynchronous child_process.spawn, keeps the event loop running (windows stay mapped and answer pings), and after exit 0 and the version check always relaunches: a last unsaved-edits question offers only Save or Discard. Quit and close are held from the spawn to the relaunch. electron-updater is still used for check and download; its install is bypassed only for this one case. VERIFIED (code paths), ASSUMED (compositor outcome, see §4 manual check)

    Why not direction 1 (hide/destroy windows, then quitAndInstall):

    • The block stays: spawnSync still freezes the UI thread for the whole prompt (row 5). Whether mutter pings an unmapped window cannot be measured here; an unmapped window receives neither focus nor input, which is what triggers mutter's check-alive. ASSUMED
    • BrowserWindow.hide() issues the unmap to the display server; it reaches the compositor only once the UI loop runs again. Calling quitAndInstall() on the same tick would block before the unmap is flushed, so the change needs a timed yield of unknown length. ASSUMED
    • Cancellation still costs two prompts (row 7) with the app invisible in between, and the windows can only come back after both.
    • Destroying windows first breaks the quit path: before-quit passes mainWindow to unsavedGuard.beforeQuit (main.js:3316, harmless when destroyed: unsaved-guard.js:61), but the PTYs are killed in mainWindow.on('closed') (main.js:422-427), so a cancelled install after destroy leaves no session to come back to. VERIFIED

    Why not direction 2 (detached helper, app exits at once):

    • On a cancel the app is already gone; the helper has to relaunch the old version, so every running PTY is lost for an update that did not happen. VERIFIED (follows from the order), the user-visible cost ASSUMED
    • The helper owns its own logging (the main process and electron-log are gone), must wait for the old PID to exit before relaunching or it loses the single-instance lock (.ai/contexts/activitywatch.md:220-223 describes that race for AppImage), and is a new shipped script. VERIFIED (doc), ASSUMED (cost)

    What the chosen direction costs: Switchboard owns the deb install command (dpkg -i, with the apt-get install -f -y fallback electron-updater has, DebUpdater.js:57-62). Running both in one root shell gives one prompt, and a 126 means the shell never ran. VERIFIED (pkexec(1), code)

    The app is live while dpkg replaces /opt/Switchboard. With the process frozen, as today, replacement is survived: main.log has lines written after the dpkg call returned (09:38:34.237–.270, Update installer has already been triggered. Quitting application.), the process quits cleanly, and the relaunch at 09:38:36.765 reports 0.0.89 as current (09:38:43.411). VERIFIED. With the process live — renderers painting, PTYs streaming, new Worker(.../workers/scan-projects.js) (session-cache.js:801,888) loading JavaScript while resources/app.asar is replaced — the outcome rests on dpkg unpacking to <file>.dpkg-new and renaming, so open files keep their old inode. ASSUMED, exercised by manual step 5.

    2 — Boundary

    This work owns Neighbouring work Shared files Landing order
    New root modules deb-update-install.js and recovery-file.js. In main.js: the write-recovery-file handler, the child_process import (:3), the updater-install handler, the update-downloaded listener and the guard's construction (:43). In unsaved-guard.js: revokeQuit(), confirmRestart() and a hold option. In public/file-panel.js: the 'update' reason of the unsaved-edits dialog. In public/app.js: one case in updaterHandler. Neither #374 nor #441 edits public/file-panel.js or unsaved-guard.js (their file lists, gh pr list). VERIFIED PR #374 (agents view, open, mergeable: CONFLICTING): hunks in main.js at @@ -1,6, -76, -425, -1690, -2328…-2674, -2936; none in the updater block (main.js:107-145) or :3104-3109; also edits preload.js, public/app.js, CHANGELOG.md main.js, public/app.js, CHANGELOG.md, .ai/contexts/ipc-bridge.md (its hunk @@ -137,7 +148,7 @@ rewrites the "Events (main → renderer)" line, HEAD :150, which lists updater-event and which this work also edits); docs/settings.md is edited by both but at :46-52 there against :59-69 here Independent; whichever lands second rebases. Certain textual collisions: CHANGELOG.md ## Unreleased and .ai/contexts/ipc-bridge.md:150. VERIFIED (gh pr diff 374, grep -n updater-event .ai/contexts/ipc-bridge.md)
    PR #441 (lazy restore, open): public/app.js, CHANGELOG.md, eslint.config.js public/app.js, CHANGELOG.md Same. VERIFIED (gh pr list)
    #472 round B (branch touched-open-route, commit f76c680, not yet a PR; worktree .claude/worktrees/agent-a249914b54bdb5c5b): removes the 'file' tab and turns the Touched stash into touchedStashes. Its public/file-panel.js rewrites collectUnsavedFileTabs and saveUnsavedFileTab and adds saveTouchedStash (f76c680:public/file-panel.js:587-616), right above showUnsavedEditsDialog public/file-panel.js, test/dom-file-panel-unsaved-guard.test.js (rebuilt on a dirty Touched editor and stashes), .ai/contexts/viewer-panel.md, CHANGELOG.md Round B merges first; #475 is rebased on it. The renderer part below is written against round B's code. VERIFIED (git worktree list, git show f76c680)
    PR #478 (window close asks, open, MERGEABLE): reason 'close' for the window close event (unsaved-guard.js:88) and a confirm in the renderer's unsaved-check handler unsaved-guard.js (the close handler, where this work inserts the hold check two lines above), public/file-panel.js (the same onUnsavedCheck handler), test/unsaved-guard.test.js, test/dom-file-panel-unsaved-guard.test.js, .ai/contexts/viewer-panel.md, CHANGELOG.md Lands before #475, which is rebased on it. 'close' stays cancellable here (cancellable: reason !== 'update'). VERIFIED (gh pr diff 478, body)
    PR #479 (save the open sessions on close, open, MERGEABLE): an appQuitting flag in before-quit (main.js:3311-3345), and in the onUnsavedCheck handler a bounded flushStateForExit() after any proceed whose reason is not 'reload' main.js (the before-quit block; this work does not edit it), public/file-panel.js (the same handler), test/dom-file-panel-unsaved-guard.test.js, CHANGELOG.md Lands before #475, which is rebased on it. Its flush also runs for 'update', and that is what the restart needs. It also runs on the install-time confirmQuit (reason 'quit', at Restart). flushStateForExit sets exitingApp = true for EXIT_FLUSH_GRACE_MS = 10000, and while it is set schedulePersistWorkingSet drops every call (if (restoringWorkingSet || exitingApp) return;, 82cf905:public/app.js:174-225). An install cancelled within those 10 s would leave the sessions opened or closed meanwhile unpersisted. Hence resumeStateAfterFailedInstall() below. VERIFIED (gh pr diff 479, file read at 82cf905)
    PR #480 ([DO NOT MERGE], a local test bundle of #441, #477, #478, #479); PR #477 (/clear follow; main.js, public/app.js, no updater lines) main.js, public/app.js, CHANGELOG.md #480 never lands. #477 is independent. VERIFIED (gh pr list --json files)

    preload.js gains one line, writeRecoveryFile: (filePath, content) => ipcRenderer.invoke('write-recovery-file', filePath, content). PR #374 also edits preload.js, so that is one more rebase point. updater-event already forwards any type (preload.js:176). VERIFIED

    3 — Scope

    The cut: on DebUpdater, not root, Linux, with a recorded downloadedFile and /usr/bin/pkexec present → async pkexec; everything else → quitAndInstall() exactly as today.

    Out of scope Owned by
    The autoInstallOnAppQuit path on a deb (normal quit): sync install after the windows close, two prompts on cancel follow-up issue to file (owner: JBR)
    The same synchronous install on PacmanUpdater follow-up issue to file (owner: JBR)
    AppImage (AppImageUpdater), Windows (NsisUpdater), macOS (MacUpdater) install paths — delegated unchanged to quitAndInstall() electron-updater
    gksudo/kdesudo/beesu/sudo selection, ELECTRON_BUILDER_LINUX_PACKAGE_MANAGER, the apt (no dpkg) branch — a missing pkexec falls back to quitAndInstall() electron-updater
    Holding the quit for the ActivityWatch flush on an install (today: never held) .ai/contexts/activitywatch.md "Quitting" — settled Q1: unchanged
    Splitting the rest of the updater wiring out of main.js (.ai/contexts/_issues.md:8) _issues.md backlog

    Specified behaviour

    deb-update-install.js exports (names binding for the tests below): createUpdateInstaller, isDebInstall, sanitizeEnv, INSTALL_SCRIPT, PKEXEC = '/usr/bin/pkexec', DPKG_QUERY = '/usr/bin/dpkg-query', DEB_PACKAGE = 'switchboard', MESSAGES. /usr/bin/dpkg-query exists here and belongs to dpkg (dpkg -S /usr/bin/dpkg-query). The scratchpad prototype proto/deb-update-install.js (132 lines) is a reference shape, not a mandate. VERIFIED (prototype runs)

    • isDebInstall({ updater, DebUpdater, platform, getuid }) → true iff platform === 'linux', updater instanceof DebUpdater, and getuid() is not 0 (root: electron-updater spawns without sudo, no prompt — delegate).
    • sanitizeEnv(env) → a copy with PATH reduced to its absolute entries, the rule of electron-updater's sanitizeEnvPath (BaseUpdater.js:96-101). VERIFIED (read)
    • createUpdateInstaller({ updater, isDeb, spawn, execFile, env, logFile, pkexecPath = PKEXEC, dpkgQueryPath = DPKG_QUERY, fsImpl = fs, confirmQuit, revokeQuit, confirmRestart, markQuitForUpdate, relaunch, quit, notify, log }) → { install(), holdQuit(), setDownloaded(info) }. setDownloaded records { file: info.downloadedFile, version: info.version }.
    • INSTALL_SCRIPT = trap "" HUP INT TERM; dpkg -i "$1" || apt-get install -f -y.
    • DEB_PACKAGE is switchboard: the name in package.json, and the package dpkg-query -W switchboard reports on this machine (switchboard 0.0.89). VERIFIED

    install():

    1. If a call is already in progress, return 'busy' (no second prompt from a double click).
    2. await confirmQuit(); on false return 'declined', nothing started. The guard runs first in every case.
    3. If not isDeb, no download recorded, or fsImpl.existsSync(pkexecPath) false: markQuitForUpdate() then updater.quitAndInstall(); return 'delegated'. This is today's handler.
    4. Remember prev = updater.autoInstallOnAppQuit, then set it to false before the spawn, so electron-updater's quit handler (BaseUpdater.js:74-88) cannot start a second, synchronous install while ours runs.
    5. revokeQuit(): the approval confirmQuit gave no longer stands, since the user can edit while the prompt is open. Enter the hold (stage password); notify('install-status', { phase: 'installing', … }).
    6. Open logFile with 'w' in the parent. Spawn pkexecPath with argv ['--disable-internal-agent', '/bin/sh', '-c', INSTALL_SCRIPT, 'sh', file], options { stdio: ['ignore', fd, fd], detached: true, env: sanitizeEnv(env) }, and no shell option. Close the parent's copy of fd once spawned. The path travels as $1, never in the script text.
    7. The hold lasts from step 5 to the moment just before quit() in step 11, or to the end of a failure.
      • During it, holdQuit() returns true and re-sends the installing phase with a message for the stage: waitingForPassword while pkexec runs, finishing from its exit to the relaunch.
      • The guard calls holdQuit() first in beforeQuit and in the window close handler, and calls preventDefault() when it returns true. ☰ Quit, the window's close button and window-all-closed therefore do nothing, and the status bar says what ends the wait.
      • The hold is released right before step 11's quit(), or that quit would be held too.
    8. Child error, or failure to open the log file → 'failed'. Exit ≠ 0 → 'cancelled' (126), 'not-authorized' (127), 'failed' (other). Each failure:
      • restores updater.autoInstallOnAppQuit = prev;
      • logs a warn with the reason, the exit code and the last 2048 bytes of logFile;
      • sends the status, releases the hold, and returns the reason.
      • Windows are never touched.
    9. Exit 0 → stage finishing; execFile(dpkgQueryPath, ['-W', '-f=${Version}', 'switchboard'], { env: sanitizeEnv(env) }), which needs no root. Expected = the downloaded version with every - replaced by ~, the deb version form (app-builder-lib/out/targets/LinuxTargetHelper.js:63-70: case "deb": return version.replace(/-/g, "~")) VERIFIED. There are three outcomes:
      • version differs → 'failed' with the mismatch message, as in step 8. This covers a script that exits 0 without installing: dpkg fails and apt-get -f has nothing to fix.
      • the query itself errors → the install is taken as done but unverified. Log warn [updater] deb installed, version not verified (<code>), keep autoInstallOnAppQuit false (no second install at quit), and continue to step 10.
      • version equal → log info [updater] deb <version> installed.
    10. Stage restart-question; await confirmRestart(). It resolves only once the renderer has answered yes, or when the window or renderer is gone. The guard asks the renderer with reason 'update'. With nothing dirty the renderer answers at once. With dirty tabs it shows the unsaved-edits dialog with Save / Discard only: no Cancel, and Escape does nothing. The old process never keeps running on a replaced /opt/Switchboard beyond this question.
    11. markQuitForUpdate(), release the hold, relaunch(), quit(); return 'installed'.

    Messages, produced in main (MESSAGES, plus the mismatch text) and sent as updater-event 'install-status' with { phase, message, durationMs }. durationMs is 0 for installing, so the text stays until replaced, and 10000 otherwise:

    phase message
    installing (sent at spawn) Installing the update… Switchboard restarts when it is done.
    installing (re-sent by a held quit/close while pkexec runs) Installing the update… Answer or cancel the password dialog to continue.
    installing (re-sent by a held quit/close after pkexec exits, before the restart question) Finishing the update… Switchboard restarts in a moment.
    installing (re-sent by a held quit/close while the restart question waits) Save or discard your edits to restart.
    cancelled Update not installed: the password prompt was cancelled. Press Restart to try again.
    not-authorized Update not installed: administrator authorization was refused.
    failed (exit ≠ 0, spawn error) Update not installed: the package manager reported an error. Details are in deb-install.log, next to main.log.
    failed (version check) Update not installed: version <installed or "none"> is installed, <expected> was expected. Details are in deb-install.log, next to main.log.

    What survives a Switchboard crash during the install. VERIFIED (stand-in), ASSUMED (real pkexec):

    • pkexec arms PR_SET_PDEATHSIG, SIGTERM (strings /usr/bin/pkexec, review 1). Before authentication pkexec itself gets SIGTERM, so no install happens. After authentication pkexec has exec'd /bin/sh, which keeps the death signal. ASSUMED (pkexec does not change euid/fsuid between prctl and execv, so the kernel does not clear it).
    • Measured with setpriv --pdeathsig TERM /bin/sh -c … standing in for pkexec, parent exiting at 200 ms, stub dpkg sleeping 1 s (scratchpad/pdeath/):
      • without the trap, the shell dies, dpkg finishes as an orphan, and the apt-get fallback never runs (E1, E4);
      • with trap "" HUP INT TERM, the fallback runs too (E3, E5);
      • ignored dispositions are inherited across exec, so dpkg and apt-get ignore SIGTERM as well.
    • Output goes to a file, not to pipes, so the parent's death cannot SIGPIPE dpkg (review 1, scratchpad/sigpipe.js vs sigpipe2.js). detached: true puts the child in its own session and process group, so a signal sent to Switchboard's group (Ctrl-C in a launching terminal) does not reach it. Measured: detaching changes nothing in the parent-exit case (E3 vs E5).
    • Whether polkit still finds the GNOME session agent for a child in its own POSIX session is ASSUMED: logind sessions are tracked by cgroup, not by setsid. Manual step 3 shows it (a 127 there means it does not).

    Logging (electron-log log, prefix [updater], in main.log):

    • info before the spawn, with pkexec path, file and log file;
    • info deb <version> installed after the version check, or warn deb installed, version not verified (<code>) when the query errors;
    • warn on failure, with the deb-install.log tail.

    dpkg's own output lands in deb-install.log in the same directory, path.join(path.dirname(log.transports.file.getFile().path), 'deb-install.log'). ASSUMED (electron-log 5.4.3 getFile() returns an object with .path; node_modules/electron-log/src/node/transports/file/index.js:123-131)

    main.js wiring:

    • main.js:3 becomes const { execFile, spawn } = require('child_process');. Today it imports only execFile, and the only spawn in the file is a local cpSpawn at :3164 (grep -nE "[^.A-Za-z_]spawn\b" main.js). VERIFIED
    • Before main.js:43: let updateInstaller = null;. The guard is built as createUnsavedGuard({ ipcMain, quit: () => app.quit(), hold: () => (updateInstaller ? updateInstaller.holdQuit() : false) }).
    • After autoUpdater = require('electron-updater').autoUpdater: updateInstaller = createUpdateInstaller({ updater: autoUpdater, isDeb: isDebInstall({ updater: autoUpdater, DebUpdater: require('electron-updater').DebUpdater, platform: process.platform, getuid: process.getuid }), spawn, execFile, env: process.env, logFile, confirmQuit: () => (mainWindow ? unsavedGuard.confirmQuit(mainWindow) : Promise.resolve(true)), revokeQuit: () => unsavedGuard.revokeQuit(), confirmRestart: () => (mainWindow ? unsavedGuard.confirmRestart(mainWindow) : Promise.resolve(true)), markQuitForUpdate: () => { activityFlushedForQuit = true; }, relaunch: () => app.relaunch(), quit: () => app.quit(), notify: sendUpdaterEvent, log }). spawn and execFile come from the main.js:3 import above.
    • The update-downloaded listener also calls updateInstaller.setDownloaded(info).
    • updater-install becomes if (!autoUpdater) return; return updateInstaller.install();.
    • unsaved-guard.js:
      • new option hold = () => false;
      • beforeQuit starts with if (hold()) { event.preventDefault(); return true; };
      • the close handler starts with if (hold()) { event.preventDefault(); return; };
      • new revokeQuit() sets quitApproved = false;
      • new option restartRetryMs = 250.
      • new async confirmRestart(win): for (;;) { if (!win || (await ask(win, 'update'))) break; await delay(restartRetryMs); }, then quitApproved = true, resolve true. The 250 ms pause bounds a renderer that answers no without showing anything to about four checks a second. Unpaced, review 4 measured 33,652 checks in 500 ms (scratchpad/loop-probe.js).
        • A false answer is never consent. It comes either from a question already in flight, which ask joins (unsaved-guard.js:28), or from a renderer prompt opened by an earlier, timed-out check that the 'update' check joined (if (unsavedPrompt) return unsavedPrompt;, f76c680:public/file-panel.js:701; :801 at 42a931d). Either way the 'update' question is asked again once that one has closed, and the next prompt is a fresh, non-cancellable one. VERIFIED (code read); the second route is reproduced in review 3 (scratchpad/confirm-restart-probe.js).
        • ask resolves true when the renderer does not acknowledge within the timeout, crashes or is destroyed (unsaved-guard.js:27-58), so the loop ends without a renderer.
      • both are returned with the others.
      • The existing 15 tests pass against this change, run in the scratchpad against a patched copy. VERIFIED
    • public/file-panel.js, against round B (f76c680). There, the dirty units are the current tab when it carries absolutePath (a Touched editor) plus every touchedStashes entry. saveUnsavedFileTab saves an entry through saveTouchedStash and an editor through handleChangesSave, and returns a reason on refusal (f76c680:public/file-panel.js:570-616). The dialog is showUnsavedEditsDialog (:618-698) and the check handler :151-158. preload.js:154 already forwards reason. VERIFIED (read)
      • The handler passes { cancellable: reason !== 'update' } to askAboutUnsavedEdits, which passes it to showUnsavedEditsDialog.
      • When not cancellable:
        • no unsaved-cancel button is created;
        • the Escape handler does nothing;
        • the hint reads The update is installed and Switchboard restarts now. Save these edits or discard them.;
        • the button-disabling loops skip the missing button.
      • A failed save under 'update':
        • For every unit whose save returned a reason, the unsaved text is written to a recovery file before the buttons are re-enabled. The text is entry.content for a stash and readChangesEditorContent(tab) for an editor.
        • The write goes through window.api.writeRecoveryFile(filePath, text).
        • A new #unsaved-recovered line reads Unsaved text kept in: <path>[; <path>…]. Discard restarts Switchboard. A unit whose recovery write fails shows <filePath> could not be kept: <error> in its place.
        • Discard then answers yes and the relaunch follows.
        • Under any other reason, a failed save writes nothing: Cancel keeps the edits in the app, as today.
      • Round B's 8 tests, run against the patched copy of f76c680:public/file-panel.js, still pass. VERIFIED
    • recovery-file.js exports writeRecoveryFile({ dir, filePath, content, now = new Date(), fsImpl = fs, log }) → { ok: true, path } or { ok: false, error }, and never throws. It also exports RECOVERY_MAX_BYTES = 16 * 1024 * 1024 and createRecoveryHandler({ dir, maxBytes = RECOVERY_MAX_BYTES, isAskingRestart, log }) → (filePath, content) => result. The handler:
      • refuses outside the restart question: { ok: false, error: 'only while an update restart is asked' }. isAskingRestart is the installer's, true only in stage restart-question;
      • refuses content over maxBytes, counted in UTF-8 bytes: { ok: false, error: 'larger than <maxBytes> bytes' }. The default is RECOVERY_MAX_BYTES, 16 MiB, kept in recovery-file.js and deliberately above the 2 MiB save cap. A Touched buffer that grew past PANEL_FILE_MAX_BYTES (main.js:228) fails to save with content too large to save (git-changes-file.js:281, :302), and it must still be kept. VERIFIED (read);
      • logs info [updater] unsaved text kept in <path> on success and warn [updater] unsaved text of <filePath> not kept: <error> on failure. That way main.log keeps the path after the relaunch.
      • Showing the paths again after the relaunch is out of scope.
      • dir is path.join(app.getPath('userData'), 'recovered'). On the deb install, userData is ~/.config/switchboard, the directory that holds Local Storage here. VERIFIED (ls ~/.config/switchboard)
      • Under SWITCHBOARD_DATA_DIR, userData follows it (main.js:27-29). VERIFIED
      • dir is created if missing (recursive) and then chmoded to 0700, so a pre-existing recovered/ with a wider mode is narrowed too. The chmod is best-effort: if it is refused (for example EPERM on a filesystem without POSIX modes under SWITCHBOARD_DATA_DIR), log.warn records [updater] recovery directory <dir> keeps its mode: <code> and the file is still written, with mode 0600.
      • The name is <basename(filePath) cut to 200 UTF-8 bytes on a character boundary>-<ISO time with : and . replaced by ->, for example notes.md-2026-10-05T12-30-01-234Z. At most 200 + 25 + 3 (-99) = 228 bytes, under the 255-byte name limit. Only the basename is used, so nothing is written outside dir and never next to the original. An empty basename becomes untitled.
      • The file is written with mode 0600 and flag wx. On EEXIST it tries -1, -2 and so on, up to 99, so an existing recovery file is never overwritten.
      • Non-string content → { ok: false, error: 'no text to keep' }.
      • main.js, placement:
        • const recoveryHandler = createRecoveryHandler({ dir: path.join(app.getPath('userData'), 'recovered'), isAskingRestart: () => (updateInstaller ? updateInstaller.isAskingRestart() : false), log }); and ipcMain.handle('write-recovery-file', (_event, filePath, content) => recoveryHandler(filePath, content)); go right after the save-file-for-panel handler (main.js:1020-1037), before // ── File Watching. That is after const PANEL_FILE_MAX_BYTES (:228) and outside the updater block (:108-145).
        • The updater block runs before the module's later consts are initialised, and only when app.isPackaged || FORCE_UPDATER. A reference from there to a later const is a TDZ ReferenceError in the released app alone, and neither lint nor the tests see it (review 5).
        • No maxBytes is passed, so the 16 MiB default applies.
        • The channel writes only inside dir, only while the restart question is open, at most 16 MiB per call, under a name main builds.
    • deb-update-install.js adds isAskingRestart() to what createUpdateInstaller returns: stage === 'restart-question'.
    • public/app.js updaterHandler: case 'install-status': setUpdaterStatus(data.message, data.durationMs); if (data.phase === 'cancelled' || data.phase === 'not-authorized' || data.phase === 'failed') resumeStateAfterFailedInstall(); break;.
    • public/app.js, conditional on (restore): save the open sessions when the app closes #479. Once (restore): save the open sessions when the app closes #479 is merged, which the landing order requires, add beside its flushStateForExit: function resumeStateAfterFailedInstall() { if (exitingAppTimer) { clearTimeout(exitingAppTimer); exitingAppTimer = null; } exitingApp = false; if (restoringWorkingSet) return _persistChain; return persistWorkingSet(); }. The restoringWorkingSet test mirrors (restore): save the open sessions when the app closes #479's own guards (82cf905:public/app.js:206, :222): during a restore (for example Restore pressed on the toast while the password prompt is open) nothing is written, and the restore's own final write records the set. Otherwise the open set is written once, and later changes persist again at once instead of after the 10 s grace. Should (updater): typing the admin password for a .deb update is interrupted by a not-responding dialog #475 ever land without (restore): save the open sessions when the app closes #479, nothing suspends persistence, and resumeStateAfterFailedInstall is function resumeStateAfterFailedInstall() {}, a no-op. Prototyped on 82cf905:public/app.js (proto/pr479/). VERIFIED (prototype) The toast is not hidden on Restart (public/app.js:1725), so Restart stays available for a retry. VERIFIED (line read)
    • The autoUpdater.on('error') reset of activityFlushedForQuit (main.js:141) stays for the delegated paths. On the deb path the flag is set only in step 11.

    4 — Acceptance criteria

    All commands are run from the repository root. Prototype in scratchpad/proto/:

    • the module deb-update-install.js;
    • recovery-file.js;
    • a patched copy of unsaved-guard.js;
    • publicB/, round B's public files from f76c680 with file-panel.js patched;
    • the tests:
      • t.test.js (T1–T16, T13b, T13c);
      • g.test.js (G1–G4);
      • rf.test.js (F1–F3);
      • domB.test.js, which is round B's test/dom-file-panel-unsaved-guard.test.js plus R1–R5.

    Results:

    • Unmutated, 92/92 pass:
      • 18 T, 5 G, and the 15 existing guard tests;
      • 14 DOM tests: round B's 8 plus R1–R6;
      • 9 F;
      • 7 on (restore): save the open sessions when the app closes #479's patched app.js in proto/pr479/test/: its 4 exit-flush tests plus X1, X2 and X3;
      • 7 W, the source tests on proto/main.patched.js (a copy of 42a931d:main.js with the wiring of this spec);
      • 17 in the existing test/activitywatch-wiring.test.js and test/auto-update-setting.test.js, run against that patched copy through proto/mirror/ (symlinks to the repo, main.js replaced). Before its rewrite, only "an update install is never held" fails there, as predicted.
    • npx eslint --stdin --stdin-filename main.js < proto/main.patched.js → 0 errors, 6 warnings, the same 6 as npx eslint main.js at HEAD.
    • Against unpatched round B, R1, R3, R4 and R5 fail.
    • bash mut8.sh ran 95 mutations, and one more, S1k (the handler moved into the updater block), ran by python3. None of the 96 survives, and each reddens the tests listed below (output in proto/mut8.out).
    • The HEAD-model renderer prototype of round 3 is kept in proto/r3head/.
      VERIFIED (prototype). ASSUMED: that the implementation has the same shape.

    Gate: task check → lint 0 errors, npm test with fail 0. ASSUMED until run

    New file test/deb-update-install.test.js, with these fakes:

    • spawn that writes one line into opts.stdio[1] and returns an EventEmitter;
    • execFile answering a chosen version;
    • pkexecPath pointing at an existing or a missing temp file;
    • real fs on a temp logFile;
    • callbacks pushing into one ordered event list.

    T15 and T16 run the real /bin/sh. Command: node --test --test-timeout=3000 test/deb-update-install.test.js → # fail 0. The timeout makes a mutant that leaves install() pending fail instead of hanging. Literals are compared, never the module's own constants, so a mutated constant reddens.

    Test Asserts Mutations that redden it (measured)
    T1 non-deb delegates isDeb:false → confirm, mark, quitAndInstall; no spawn M0 delete markQuitForUpdate() from the delegate path (also T6, T8)
    T2 a declined guard starts nothing confirmQuit→false → 'declined', events confirm only M1 replace if (!(await confirmQuit())) return 'declined' with await confirmQuit()
    T3 deb success Order confirm, revoke, status:installing, spawn. Argv deep-equals the literal ['--disable-internal-agent','/bin/sh','-c','trap "" HUP INT TERM; dpkg -i "$1" || apt-get install -f -y','sh','/c/a b/u.deb']. opts.shell is undefined, detached === true, stdio[0]==='ignore', stdio[1]===stdio[2] is a number, env.PATH === '/usr/bin:/bin' from '/usr/bin:./node_modules/.bin::/bin'. autoInstallOnAppQuit === false before the child exits. The log file holds the child's line. After close 0: query:/usr/bin/dpkg-query -W -f=${Version} switchboard, confirmRestart, mark, relaunch, quit. The info log contains deb 0.0.90 installed M3 no autoInstallOnAppQuit = false; M4 no relaunch(); M5 no revokeQuit(); M13 file dropped from argv; S1 no --disable-internal-agent; S5 shell: true; S6 file interpolated into the script; S7 no trap; S8 'pipe' stdio; S9 no detached; S10 unsanitized env; S21 package name Switchboard; N1b no confirmRestart(); N6 DPKG_QUERY = 'dpkg-query'; N6c success log without the version
    T4 cancel (126) 'cancelled'; events … spawn, status:cancelled, no quit/quitAndInstall. Status deep-equals {phase:'cancelled', message:<cancelled text>, durationMs:10000}. autoInstallOnAppQuit restored to true. The warn has exit 126 and the log-file line M6 drop the 126 mapping; S12 change the cancelled text; S13 drop message; S14 no restore; S20 no tail in the warn
    T5 127 and other codes 127 → 'not-authorized' and its text; 1 → 'failed' and its text; flag restored M7 drop the 127 mapping; S13; S14
    T6 absolute paths PKEXEC === '/usr/bin/pkexec', DPKG_QUERY === '/usr/bin/dpkg-query'; a missing pkexecPath → 'delegated', confirm, mark, quitAndInstall M8 drop the existsSync check; S11 PKEXEC = 'pkexec'; N6
    T7 double click a second install() while pending → 'busy', one child M9 drop the busy check
    T8 no recorded download delegates, no spawn M10 drop the download check
    T9 isDebInstall linux+DebUpdater+uid 1000 → true; other class, uid 0, win32 → false M11 drop the root exclusion; M12 drop instanceof
    T10 exit 0: mismatch vs query error Installed 0.0.89 → 'failed' with the mismatch text (0.0.89, 0.0.90), no relaunch/quit, flag restored. Query error (ENOENT) → 'installed', ending confirmRestart, mark, relaunch, quit, flag stays false, a warn containing version not verified, and no failed status S15 skip the mismatch check; N6b treat a query error as a mismatch; S13; S14; M3; M4
    T11 prerelease downloaded 0.1.0-beta.1, installed 0.1.0~beta.1 → 'installed' S16 compare without the -→~ mapping
    T12 the restart question cannot keep the old process confirmRestart resolving false → still 'installed', ending confirmRestart, mark, relaunch, quit, one child N1 if (!(await confirmRestart())) return 'deferred'; N1b; M4
    T13 hold from spawn to relaunch (guard + installer together) While pkexec is pending, guard.beforeQuit → true and prevented, and window close is prevented. Status: Installing the update… Answer or cancel the password dialog to continue. After close 0, with the dpkg-query answer withheld, a quit is still prevented. Status: Finishing the update… Switchboard restarts in a moment. No unsaved-check is sent, no quit happens. When the injected quit() runs, holdQuit() is already false S19 holdQuit always false; N4 release the hold at the child's exit; N4b keep the hold through the own quit(); N5 change the password text; N5b one text for both stages; G2; G3
    T13c a quit while the restart question waits confirmRestart withheld after close 0 and the query → holdQuit() true, isAskingRestart() true, status Save or discard your edits to restart., no quit; after the answer isAskingRestart() false. T3 also asserts isAskingRestart() false during the password prompt P3 no restart-question stage; P3b change its text; Q2d isAskingRestart true in any stage (T3)
    T13b a failure releases the hold after close 126, holdQuit() → false N4c no hold release at the end of install()
    T14 sanitizeEnv '.:/usr/bin:bin::/bin' → '/usr/bin:/bin', other keys kept (pinned through T3 by S10)
    T15 the real script stub dpkg/apt-get first on PATH, deb path …/s p'q"-…/it's a "deb" $(x);.deb. dpkg 0 → only dpkg|-i|<path intact>|, status 0. dpkg 1 → dpkg then apt-get|install|-f|-y|, status 0. apt 100 → status 100 S2 unquoted $1; S3 no fallback; S4 ; for ||
    T16 the real script under SIGTERM SIGTERM to the shell at 150 ms while the dpkg stub sleeps 0.4 s and exits 1 → apt-get still runs S7 no trap; S2; S3

    Additions to existing tests:

    Test Command / pass Mutation
    test/unsaved-guard.test.js G1: after confirmQuit yes and then revokeQuit(), beforeQuit returns true, is prevented and sends a second unsaved-check; a window close is then held. VERIFIED node --test test/unsaved-guard.test.js → # fail 0 G1 empty revokeQuit
    test/unsaved-guard.test.js G2: with hold: () => true, beforeQuit → true and prevented, close prevented, nothing sent. VERIFIED same G2 drop the hold check in beforeQuit; G3 drop it in close
    test/unsaved-guard.test.js G3: with a reload question in flight, confirmRestart(win) sends nothing until it is answered (with Cancel), then sends unsaved-check with reason 'update'; on its answer it resolves true and the next beforeQuit returns false. VERIFIED same G5 reason 'quit'; G6 no quitApproved = true; P4 ignore the answer
    test/unsaved-guard.test.js G4: confirmRestart sends 'update'. Answered false → a second 'update' check, and beforeQuit still holds. Answered true → resolves true, and beforeQuit returns false. VERIFIED same P4 ignore the answer (await ask(...); break;); G5
    test/dom-file-panel-unsaved-guard.test.js (round B's fixtures: dirtyTab opens a Touched editor through onMcpOpenFile({filePath}); openDiffTab stashes it). R1: dirty editor, reason 'update' → no #unsaved-cancel, the update hint, Escape leaves it open with no answer, Discard answers {proceed:true}. VERIFIED node --test test/dom-file-panel-unsaved-guard.test.js → # fail 0 R1 reason ignored; R3 Escape always cancels; R4 Cancel always created; R5 hint text changed
    R2: reason 'quit' → #unsaved-cancel present, Escape answers {proceed:false}. VERIFIED same R2 cancellable: false for every reason
    R3: dirty editor made stale, reason 'update', Save → error shown; writeRecoveryFile called once with ('/repo/a.md', 'x0\nmine'); #unsaved-recovered reads Unsaved text kept in: /data/recovered/a.md-T. Discard restarts Switchboard.; no answer yet; Discard enabled → {proceed:true}. VERIFIED same P2 no recovery write; P2c text changed; P2d empty text written; R1
    R4: the same failed save under 'quit' → writeRecoveryFile not called, #unsaved-recovered hidden. VERIFIED same P2b recovery for every reason; R2
    R6: a dirty editor of 2 MiB + 1 byte whose save fails with content too large to save (the fake save applies the 2 MiB cap), reason 'update', Save → writeRecoveryFile called once with the full text, and the path shown. VERIFIED same P2; R1
    R5: two stashes (A in s1, C in s2), A made stale, reason 'update', Save → C saved to disk, writeRecoveryFile called once, with ('/repo/a.md', 'x0\none'), the path shown, Discard → {proceed:true}. VERIFIED same P2e stash text read from the editor instead of entry.content; P2; P2d
    New test/recovery-file.test.js F1: /home/u/proj/notes.md at 2026-10-05T12:30:01.234Z → <dir>/notes.md-2026-10-05T12-30-01-234Z, content intact, file mode 0600, dir mode 0700. VERIFIED node --test test/recovery-file.test.js → # fail 0 F1 file mode 0644; Q3 no chmod; F2b full path instead of the basename
    F1b: a pre-existing recovered/ at 0755 is 0700 after a write. VERIFIED same Q3 no chmod
    F2: ../../etc/x lands in dir; a second x at the same instant gets -1 and the first keeps its content. VERIFIED same F2 flag w (overwrite); F2b
    F3: a parent that is a regular file → {ok:false, error:<string>}, no throw; content: null → {ok:false, error:'no text to keep'}. VERIFIED same F3 rethrow; F3b drop the content check
    F4: basenames of 300 ASCII bytes, of é×150 + .md, and of 😀×80 are all written. Each name is ≤ 255 bytes, its stem is 197–200 bytes, a prefix of whole characters, with no U+FFFD. VERIFIED same Q1 budget 300; Q1b no truncation
    F5: handler with isAskingRestart: () => false → {ok:false, error:'only while an update restart is asked'}, dir not created. VERIFIED same Q2 no stage gate
    F6: RECOVERY_MAX_BYTES === 16 MiB. With the default cap, 'x' × (2 MiB + 1), a buffer too large to save, is kept; é × 8 Mi (exactly 16 MiB) is kept; é × (8 Mi + 1) is refused with larger than 16777216 bytes. VERIFIED same S2x cap = 2 MiB (PANEL_FILE_MAX_BYTES); Q2b no cap; Q2c cap counted in characters
    F8: an fsImpl whose chmodSync throws EPERM → {ok:true}, the text written with mode 0600, and one warn [updater] recovery directory <dir> keeps its mode: EPERM. VERIFIED same S3x chmod not caught; S3y caught without the warn; Q3
    F7: a successful handler call logs exactly info [updater] unsaved text kept in <dir>/notes.md-2026-10-05T12-30-01-234Z. VERIFIED same Q4 no log line
    test/unsaved-guard.test.js G5: a renderer that answers no at once (on the next setImmediate) gets 2–4 'update' checks in 600 ms. VERIFIED node --test test/unsaved-guard.test.js → # fail 0 Q5 no pause; Q5b restartRetryMs = 0
    test/exit-flush.test.js (#479's file) X1: open a, flushStateForExit(), open b, schedule (dropped), then resumeStateAfterFailedInstall() → writes [['a'], ['a','b']], grace timer cleared; open c, schedule, run timers → last write ['a','b','c']. VERIFIED on 82cf905 node --test test/exit-flush.test.js → # fail 0 Q6 exitingApp left true; Q6b no persistWorkingSet()
    X3: flushStateForExit(), then restoringWorkingSet = true, then resumeStateAfterFailedInstall() → no write beyond the first, exitingApp false. VERIFIED on 82cf905 node --test test/exit-flush.test.js → # fail 0 S4x drop the restoringWorkingSet guard
    X2 (source test on public/app.js): the install-status case calls setUpdaterStatus(data.message, data.durationMs) and resumeStateAfterFailedInstall() on cancelled, not-authorized and failed. VERIFIED on 82cf905 same Q6c call removed; Q6d cancelled left out
    test/activitywatch-wiring.test.js:66-70, "an update install is never held", rewritten. main.js passes markQuitForUpdate: () => { activityFlushedForQuit = true; }, and deb-update-install.js calls markQuitForUpdate() before updater.quitAndInstall() in the delegate path and before quit() in the restart path (source order). Today's version fails by construction once quitAndInstall leaves the handler. VERIFIED (rewritten test run on proto/mirror/) node --test test/activitywatch-wiring.test.js → # fail 0 S1l markQuitForUpdate: () => {} (also W3)
    New test/updater-wiring.test.js, source tests on main.js (W1–W7, prototyped as proto/wiring.test.js). VERIFIED on proto/main.patched.js; all 7 fail on 42a931d:main.js node --test test/updater-wiring.test.js → # fail 0 per row below
    W1 const { execFile, spawn } = require('child_process'); S1a drop spawn
    W2 let updateInstaller = null; precedes createUnsavedGuard(, built with hold: () => (updateInstaller ? updateInstaller.holdQuit() : false) S1b hold: () => false
    W3 in updateInstaller = createUpdateInstaller({…}): confirmQuit → unsavedGuard.confirmQuit(mainWindow), revokeQuit → unsavedGuard.revokeQuit(), confirmRestart → unsavedGuard.confirmRestart(mainWindow), markQuitForUpdate sets activityFlushedForQuit, DebUpdater: require('electron-updater').DebUpdater S1c confirmQuit always true; S1d revokeQuit no-op; S1e confirmRestart always true; S1l flag not set
    W4 the update-downloaded listener calls updateInstaller.setDownloaded(info) S1f delete the call
    W5 updater-install returns updateInstaller.install() and holds no quitAndInstall S1g call autoUpdater.quitAndInstall() there
    W6 createRecoveryHandler({…}) has dir: path.join(app.getPath('userData'), 'recovered') and the isAskingRestart closure, passes no maxBytes, and write-recovery-file delegates to it S1h gate always open; S1i maxBytes: PANEL_FILE_MAX_BYTES; S1j dir: os.tmpdir()
    W7 the createRecoveryHandler({ call comes after const PANEL_FILE_MAX_BYTES and after the end of the if (app.isPackaged || process.env.FORCE_UPDATER) { block S1k move the handler and its ipcMain.handle into the updater block
    Source test on public/app.js: updaterHandler has case 'install-status': calling setUpdaterStatus(data.message, data.durationMs). Covered by X2. VERIFIED on 82cf905 → # fail 0 delete the case (X2)

    No existing test covers the updater install: grep -rln "quitAndInstall\|updater-install" test/ → only test/activitywatch-wiring.test.js (flag order) and test/unsaved-guard.test.js:219 (guard semantics); test/auto-update-setting.test.js covers the setting gates. VERIFIED

    Manual check on the real deb install (Ubuntu, GNOME, Wayland — the only place the compositor behaviour shows). ASSUMED (procedure not run)

    The frozen process today is the baseline: main.log has no line between 09:38:14.789 (pkexec) and 09:38:34.237, which is 19.4 s against mutter's 5000 ms check-alive-timeout. VERIFIED. After the fix, [updater], [status] and PTY lines keep appearing in main.log during the prompt.

    1. On the branch, run npm version --no-git-tag-version 0.0.88. That is lower than the latest published release, v0.0.89, so the installed build finds an update. Then run npm run bundle:codemirror && npx electron-builder --linux deb --config.npmRebuild=false. Do not commit the version change.
    2. Quit Switchboard, run sudo dpkg -i dist/switchboard_0.0.88_amd64.deb, launch it, and wait for the v0.0.89 ready toast (5 s check plus download).
    3. Click Restart. The status bar reads Installing the update…. In the authentication dialog, type for more than 10 s. Meanwhile try ☰ Quit once: the status bar reads Installing the update… Answer or cancel the password dialog to continue. Then press Cancel. Pass if all of these hold:
      • no "Switchboard is not responding" dialog appeared;
      • a single dialog appeared, not two;
      • Switchboard is usable, with its terminals still running;
      • the status bar reads Update not installed: the password prompt was cancelled. Press Restart to try again.;
      • the toast still offers Restart;
      • grep '\[updater\]' ~/.config/switchboard/logs/main.log | tail -3 shows the pkexec line and deb install cancelled (exit 126).
    4. Open a file panel and make an unsaved edit, then use ☰ Quit. Pass: the unsaved-edits question appears. Cancel it.
    5. Click Restart again. While the dialog is open, use ☰ Quit and the window's close button. Pass: nothing closes, and the status bar shows Installing the update…. Submit the password. Until the relaunch, keep typing in a terminal and press the search bar's refresh button, which starts a full worker reindex (public/app.js:858-870). While the password dialog is open, chmod 444 the file edited in step 4. The unsaved-edits question for that edit appears with Save and Discard only, and Escape does not close it. A quit attempted now shows Save or discard your edits to restart. Press Save: it fails, and the dialog names a file under ~/.config/switchboard/recovered/. Check that the file holds the edit and is mode 600 (stat -c %a). Press Discard. Pass if all of these hold:
      • no "not responding" dialog;
      • no renderer crash, render-process-gone or worker error in main.log between deb install: and the relaunch;
      • the app relaunches, Settings → Version reads 0.0.89, and dpkg -l switchboard reports 0.0.89;
      • main.log shows deb 0.0.89 installed (or version not verified), and at quit Update will not be installed on quit because autoInstallOnAppQuit is set to false, not a second install;
      • ~/.config/switchboard/logs/deb-install.log holds dpkg's Unpacking/Setting up lines.

    5 — Constraints the implementer inherits

    Copied from .ai/shared-guidelines.md (VERIFIED, read at HEAD):

    • "NEVER run npm run build:linux (or task build) while the user's AppImage is running without explicit confirmation." and "Building while running is safe with --config.npmRebuild=false" — on this machine the running instance is the deb at /opt/Switchboard; the rebuild concern applies to the dev node_modules, the manual check builds with --config.npmRebuild=false.
    • "Use SWITCHBOARD_DATA_DIR isolation if you genuinely need a live process, otherwise stay read-only / unit-test-driven." — the deb install path cannot run in an isolated dev instance (autoUpdater is null unless packaged or FORCE_UPDATER, main.js:109; package-type exists only under a packaged resourcesPath). VERIFIED
    • "Comment sweep. … What may remain in code: at most a one-line pointer to that doc … and that's the ceiling (maintainer rule, PRs perf(sidebar): discrete steps() indicator animations + ADR 0002 #127/fix(sidebar): live running indicator for subagents #130)."
    • "Every PR that changes behaviour adds its entry under ## Unreleased in CHANGELOG.md" ; "task check (lint + test). 0 errors." ; "No Co-Authored-By. Imperative subject, brief why-body." ; "gh pr create against devsuitup/switchboard:main … Title format: (area): short imperative." ; request devsuitup as reviewer when ready.
    • "Default value for a setting | SETTING_DEFAULTS in setting-defaults.js — never a literal at the call site" (no new setting is specified).
    • Renderer tests: "installSpies: false is required when the eval defines functions you also spy on" (only if a jsdom test of app.js is chosen over the source test).

    Service conventions (VERIFIED): no pyproject.toml — this is Node. package.json scripts.test = node scripts/run-tests.js (stage 1 at 4 workers, every test/*.js), scripts.lint = eslint .; Taskfile.yaml:90-92 check: deps: [test, lint]. build.files includes *.js, so a new root module ships without config. Main-process modules are 'use strict' CommonJS factories with injected dependencies (unsaved-guard.js:7, createUnsavedGuard({ ipcMain, timeoutMs, setTimeoutFn, … })).

    Must not break (VERIFIED, existing tests): test/unsaved-guard.test.js (incl. :219 install semantics), test/activitywatch-wiring.test.js (:58 hold once, :66 install never held — to be rewritten, :82 error resets the flag), test/auto-update-setting.test.js (autoInstallOnAppQuit = autoUpdate assignment at main.js:3294 must stay as is).

    Docs to update:

    • docs/settings.md:59-69 (Updates): one paragraph — on a .deb install, Restart asks for the administrator password with Switchboard still open and unable to quit until the install ends; dpkg's output is in deb-install.log next to main.log; edits that cannot be saved before the restart are kept in ~/.config/switchboard/recovered/; a cancel keeps the app and the downloaded update, and Restart can be pressed again; with Automatic Updates on, an update not installed by Restart installs at the next quit, which asks for the password after the window has closed. docs/releasing.md has no install section (only :68-71, on manifests) — no change. VERIFIED
    • .ai/contexts/viewer-panel.md:170: updater-install asks first; a deb install revokes the approval when its prompt opens and, after a verified install, asks once more with reason 'update', which the renderer answers with Save or Discard only, asked again until it answers yes; a save that fails there writes the text to <userData>/recovered/; hold keeps quit and close from going through from the spawn to the relaunch.
    • .ai/contexts/activitywatch.md:217-223 "Quitting": the flag is set by markQuitForUpdate, just before quitAndInstall() or, on a deb, just before the quit that follows a verified install.
    • docs/session-restore.md ("Closing the app", added by (restore): save the open sessions when the app closes #479): one sentence — an update install that fails or is cancelled saves the open sessions at once and resumes saving.
    • .ai/contexts/ipc-bridge.md:136: updater-install → deb-update-install.js / electron-updater; :150 (shared with PR (agents): a view of the sessions the claude daemon runs in the background #374): updater-event gains install-status; new invoke channel write-recovery-file.
    • No .ai/contexts/* file covers the updater (grep -rln updater .ai/contexts → _issues.md, activitywatch.md, ipc-bridge.md, viewer-panel.md). The rationale (sync spawn, chosen direction) goes in a short section of one of them, or docs/decisions/ — not in code comments. VERIFIED (grep)

    CHANGELOG entry, under ## Unreleased → ### Fixed (rule of docs/changelog.md:44-56):

    - On a `.deb` install, typing the administrator password for **Restart** is no longer interrupted by a "Switchboard is not responding" dialog. Cancelling the password keeps Switchboard open with the update still ready. (#475)
    

    6 — Fits one go

    Code, tests, docs and the CHANGELOG fit a single dispatch. The new module is about 130 lines (the prototype has 132). recovery-file.js is about 60 lines. The change adds about 25 lines to main.js, 15 to unsaved-guard.js, about 35 to public/file-panel.js, 1 to preload.js and about 10 to public/app.js. The dispatch starts only once round B, #478 and #479 have merged; until then the rebase cannot be done. ASSUMED (estimated from the prototype)

    A second round follows the dispatch: one CI round trip, and JBR running the manual check (§4) on the deb install, with a build and a sudo dpkg -i. ASSUMED

    7 — Open questions

    None open.

    • Q1 (hold the quit for the ActivityWatch flush after a deb install) is settled no: the install path stays unheld.
    • Q2 (keep the apt-get install -f -y fallback) is settled yes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions