Repository navigation
(updater): typing the admin password for a .deb update is interrupted by a not-responding dialog #475
Description
Activity
Spec — #475: a .deb update does not block the main process while the password is asked
- Repository:
/home/jean-baptiste-renard/workspace/tools/switchboard(forkdevsuitup/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 at375decbf8c14543b7f8ee7783da85618116cc7de.git diff --stat 375decb 42a931dtouches 9 files, none of themmain.js,unsaved-guard.js,preload.js,public/app.js,package-lock.jsonornode_modules. So everymain.js, guard, preload, app.js and electron-updater citation holds at42a931d. Thepublic/file-panel.jslines are cited at round B (f76c680, which contains42a931d), and.ai/contexts/viewer-panel.md:170is 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.89installed as a deb,/opt/Switchboard/resources/package-type=debVERIFIED
1 — Claims of the issue, measured at HEAD
# Claim Status Evidence 1 updater-installismain.js:3104-3109and callsquitAndInstall()with the windows mappedSTILL PRESENT main.js:3104opens the handler,:3106confirmQuit,:3107setsactivityFlushedForQuit = true,:3108autoUpdater.quitAndInstall(); no hide/destroy anywhere in it. VERIFIED2 BaseUpdater.quitAndInstallrunsinstall()thenapp.quit()STILL PRESENT BaseUpdater.js:13-27:install()is called synchronously at:16; only when it returns true doessetImmediate(:18) emitbefore-quit-for-updateand callthis.app.quit()(:21). On false it resetsquitAndInstallCalledand does not quit. VERIFIED3 DebUpdater.doInstallrunsdpkg -ithroughrunCommandWithSudoIfNeeded, wrapped in the first ofgksudo,kdesudo,pkexec,beesuSTILL PRESENT DebUpdater.js:40→installWithCommandRunner→commandRunner(["dpkg","-i",path]);LinuxUpdater.js:34-51+determineSudoCommand(list["gksudo","kdesudo","pkexec","beesu"], defaultsudo); pkexec gets--disable-internal-agent. VERIFIED4 The spawn is synchronous ( spawnSyncLog)STILL PRESENT BaseUpdater.js:102-121:child_process.spawnSync(cmd, args, {shell: true, ...}). VERIFIED5 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 line09: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. VERIFIED6 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). ASSUMED7 A cancelled authentication ( pkexec126) leaves the app running; electron-updater dispatches an errorSTILL PRESENT, incomplete A dpkg failure of any cause, a 126 included, is caught at DebUpdater.js:57-62and followed bycommandRunner(["apt-get","install","-f","-y"])— a second pkexec prompt. Only when that one fails too doesdoInstalldispatch the error and return false (DebUpdater.js:41-44). Simulated: a runner that throwsexited with code 126is called twice,dpkg -i /tmp/x.debthenapt-get install -f -y, then rethrows. VERIFIED8 "The ActivityWatch flush still runs" (as a property to keep) REFUTED for the install path updater-installsetsactivityFlushedForQuit = true(main.js:3107) sobefore-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-70pins it.activityReporter.stop()still runs (main.js:3324). VERIFIED9 "The unsaved-edits guard runs before anything is hidden" STILL PRESENT main.js:3106awaitsunsavedGuard.confirmQuit(mainWindow)beforequitAndInstall. VERIFIED10 AppImage, Windows, macOS are distinct updaters STILL PRESENT electron-updater/out/main.js:42-77:NsisUpdateron win32,MacUpdateron darwin, otherwiseAppImageUpdaterreplaced byDebUpdater/RpmUpdater/PacmanUpdaterwhenprocess.resourcesPath/package-typereadsdeb/rpm/pacman. VERIFIEDFindings the issue does not state:
- After a cancelled install,
unsavedGuardstays approved:confirmQuitsetsquitApproved = 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
autoInstallOnAppQuitpath (normal quit with a downloaded update):addQuitHandlerregistersapp.once('quit')(BaseUpdater.js:69-90,ElectronAppAdapter.js:37-39); it callsinstall(true, false)— same synchronouspkexec dpkg -i, no relaunch.quitis emitted afterwill-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 synchronouspkexecshape;pacmanis a shipped target (package.jsonbuild.linux.target=AppImage, deb, pacman). VERIFIED- Every
update-downloadedevent carriesdownloadedFile(AppUpdater.js:592-595, typed intypes.d.ts:35), also for a cached download (AppUpdater.js:604-607goes through the samedone). This is the public way to get the file;installerPath/downloadedUpdateHelperareprotected(BaseUpdater.d.ts:11,AppUpdater.d.ts:100). VERIFIED DebUpdateris exported (electron-updater/out/main.js:29-30);instanceofseparates the classes: AppImageUpdater→false, PacmanUpdater→false, DebUpdater→true (node one-liner run at HEAD).LinuxUpdateris 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-installrunspkexecitself with an asynchronouschild_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:
spawnSyncstill 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. CallingquitAndInstall()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-quitpassesmainWindowtounsavedGuard.beforeQuit(main.js:3316, harmless when destroyed:unsaved-guard.js:61), but the PTYs are killed inmainWindow.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-223describes 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 theapt-get install -f -yfallback 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.loghas 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 at09:38:36.765reports0.0.89as 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 whileresources/app.asaris replaced — the outcome rests on dpkg unpacking to<file>.dpkg-newand 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.jsandrecovery-file.js. Inmain.js: thewrite-recovery-filehandler, thechild_processimport (:3), theupdater-installhandler, theupdate-downloadedlistener and the guard's construction (:43). Inunsaved-guard.js:revokeQuit(),confirmRestart()and aholdoption. Inpublic/file-panel.js: the'update'reason of the unsaved-edits dialog. Inpublic/app.js: onecaseinupdaterHandler. Neither #374 nor #441 editspublic/file-panel.jsorunsaved-guard.js(their file lists,gh pr list). VERIFIEDPR #374 (agents view, open, mergeable: CONFLICTING): hunks inmain.jsat@@ -1,6,-76,-425,-1690,-2328…-2674,-2936; none in the updater block (main.js:107-145) or:3104-3109; also editspreload.js,public/app.js,CHANGELOG.mdmain.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 listsupdater-eventand which this work also edits);docs/settings.mdis edited by both but at:46-52there against:59-69hereIndependent; whichever lands second rebases. Certain textual collisions: CHANGELOG.md## Unreleasedand.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.jspublic/app.js,CHANGELOG.mdSame. VERIFIED ( gh pr list)#472 round B (branch touched-open-route, commitf76c680, not yet a PR; worktree.claude/worktrees/agent-a249914b54bdb5c5b): removes the'file'tab and turns the Touched stash intotouchedStashes. Itspublic/file-panel.jsrewritescollectUnsavedFileTabsandsaveUnsavedFileTaband addssaveTouchedStash(f76c680:public/file-panel.js:587-616), right aboveshowUnsavedEditsDialogpublic/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.mdRound 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 windowcloseevent (unsaved-guard.js:88) and a confirm in the renderer'sunsaved-checkhandlerunsaved-guard.js(theclosehandler, where this work inserts the hold check two lines above),public/file-panel.js(the sameonUnsavedCheckhandler),test/unsaved-guard.test.js,test/dom-file-panel-unsaved-guard.test.js,.ai/contexts/viewer-panel.md,CHANGELOG.mdLands 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): anappQuittingflag inbefore-quit(main.js:3311-3345), and in theonUnsavedCheckhandler a boundedflushStateForExit()after anyproceedwhose reason is not'reload'main.js(thebefore-quitblock; this work does not edit it),public/file-panel.js(the same handler),test/dom-file-panel-unsaved-guard.test.js,CHANGELOG.mdLands 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-timeconfirmQuit(reason'quit', at Restart).flushStateForExitsetsexitingApp = trueforEXIT_FLUSH_GRACE_MS = 10000, and while it is setschedulePersistWorkingSetdrops 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. HenceresumeStateAfterFailedInstall()below. VERIFIED (gh pr diff 479, file read at82cf905)PR #480 ( [DO NOT MERGE], a local test bundle of #441, #477, #478, #479); PR #477 (/clearfollow;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.jsgains one line,writeRecoveryFile: (filePath, content) => ipcRenderer.invoke('write-recovery-file', filePath, content). PR #374 also editspreload.js, so that is one more rebase point.updater-eventalready forwards anytype(preload.js:176). VERIFIED3 — Scope
The cut: on
DebUpdater, not root, Linux, with a recordeddownloadedFileand/usr/bin/pkexecpresent → asyncpkexec; everything else →quitAndInstall()exactly as today.Out of scope Owned by The autoInstallOnAppQuitpath on a deb (normal quit): sync install after the windows close, two prompts on cancelfollow-up issue to file (owner: JBR) The same synchronous install on PacmanUpdaterfollow-up issue to file (owner: JBR) AppImage ( AppImageUpdater), Windows (NsisUpdater), macOS (MacUpdater) install paths — delegated unchanged toquitAndInstall()electron-updater gksudo/kdesudo/beesu/sudoselection,ELECTRON_BUILDER_LINUX_PACKAGE_MANAGER, theapt(no dpkg) branch — a missingpkexecfalls back toquitAndInstall()electron-updater Holding the quit for the ActivityWatch flush on an install (today: never held) .ai/contexts/activitywatch.md"Quitting" — settled Q1: unchangedSplitting the rest of the updater wiring out of main.js(.ai/contexts/_issues.md:8)_issues.mdbacklogSpecified behaviour
deb-update-install.jsexports (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-queryexists here and belongs todpkg(dpkg -S /usr/bin/dpkg-query). The scratchpad prototypeproto/deb-update-install.js(132 lines) is a reference shape, not a mandate. VERIFIED (prototype runs)isDebInstall({ updater, DebUpdater, platform, getuid })→ true iffplatform === 'linux',updater instanceof DebUpdater, andgetuid()is not 0 (root: electron-updater spawns without sudo, no prompt — delegate).sanitizeEnv(env)→ a copy withPATHreduced to its absolute entries, the rule of electron-updater'ssanitizeEnvPath(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) }.setDownloadedrecords{ file: info.downloadedFile, version: info.version }.INSTALL_SCRIPT=trap "" HUP INT TERM; dpkg -i "$1" || apt-get install -f -y.DEB_PACKAGEisswitchboard: thenameinpackage.json, and the packagedpkg-query -W switchboardreports on this machine (switchboard 0.0.89). VERIFIED
install():- If a call is already in progress, return
'busy'(no second prompt from a double click). await confirmQuit(); on false return'declined', nothing started. The guard runs first in every case.- If not
isDeb, no download recorded, orfsImpl.existsSync(pkexecPath)false:markQuitForUpdate()thenupdater.quitAndInstall(); return'delegated'. This is today's handler. - Remember
prev = updater.autoInstallOnAppQuit, then set it tofalsebefore the spawn, so electron-updater'squithandler (BaseUpdater.js:74-88) cannot start a second, synchronous install while ours runs. revokeQuit(): the approvalconfirmQuitgave no longer stands, since the user can edit while the prompt is open. Enter the hold (stagepassword);notify('install-status', { phase: 'installing', … }).- Open
logFilewith'w'in the parent. SpawnpkexecPathwith argv['--disable-internal-agent', '/bin/sh', '-c', INSTALL_SCRIPT, 'sh', file], options{ stdio: ['ignore', fd, fd], detached: true, env: sanitizeEnv(env) }, and noshelloption. Close the parent's copy offdonce spawned. The path travels as$1, never in the script text. - 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 theinstallingphase with a message for the stage:waitingForPasswordwhile pkexec runs,finishingfrom its exit to the relaunch. - The guard calls
holdQuit()first inbeforeQuitand in the windowclosehandler, and callspreventDefault()when it returns true. ☰ Quit, the window's close button andwindow-all-closedtherefore 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.
- During it,
- 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
warnwith the reason, the exit code and the last 2048 bytes oflogFile; - sends the status, releases the hold, and returns the reason.
- Windows are never touched.
- restores
- 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 andapt-get -fhas nothing to fix. - the query itself errors → the install is taken as done but unverified. Log
warn[updater] deb installed, version not verified (<code>), keepautoInstallOnAppQuitfalse (no second install at quit), and continue to step 10. - version equal → log
info[updater] deb <version> installed.
- version differs →
- 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/Switchboardbeyond this question. markQuitForUpdate(), release the hold,relaunch(),quit(); return'installed'.
Messages, produced in main (
MESSAGES, plus the mismatch text) and sent asupdater-event'install-status'with{ phase, message, durationMs }.durationMsis 0 forinstalling, 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.cancelledUpdate not installed: the password prompt was cancelled. Press Restart to try again.not-authorizedUpdate 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 betweenprctlandexecv, so the kernel does not clear it). - Measured with
setpriv --pdeathsig TERM /bin/sh -c …standing in for pkexec, parent exiting at 200 ms, stubdpkgsleeping 1 s (scratchpad/pdeath/):- without the
trap, the shell dies, dpkg finishes as an orphan, and theapt-getfallback 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.
- without the
- Output goes to a file, not to pipes, so the parent's death cannot SIGPIPE dpkg (review 1,
scratchpad/sigpipe.jsvssigpipe2.js).detached: trueputs 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], inmain.log):infobefore the spawn, with pkexec path, file and log file;infodeb <version> installedafter the version check, orwarndeb installed, version not verified (<code>)when the query errors;warnon failure, with thedeb-install.logtail.
dpkg's own output lands in
deb-install.login the same directory,path.join(path.dirname(log.transports.file.getFile().path), 'deb-install.log'). ASSUMED (electron-log 5.4.3getFile()returns an object with.path;node_modules/electron-log/src/node/transports/file/index.js:123-131)main.jswiring:main.js:3becomesconst { execFile, spawn } = require('child_process');. Today it imports onlyexecFile, and the onlyspawnin the file is a localcpSpawnat:3164(grep -nE "[^.A-Za-z_]spawn\b" main.js). VERIFIED- Before
main.js:43:let updateInstaller = null;. The guard is built ascreateUnsavedGuard({ 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 }).spawnandexecFilecome from themain.js:3import above. - The
update-downloadedlistener also callsupdateInstaller.setDownloaded(info). updater-installbecomesif (!autoUpdater) return; return updateInstaller.install();.unsaved-guard.js:- new option
hold = () => false; beforeQuitstarts withif (hold()) { event.preventDefault(); return true; };- the
closehandler starts withif (hold()) { event.preventDefault(); return; }; - new
revokeQuit()setsquitApproved = false; - new option
restartRetryMs = 250. - new
async confirmRestart(win):for (;;) { if (!win || (await ask(win, 'update'))) break; await delay(restartRetryMs); }, thenquitApproved = 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
askjoins (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;:801at42a931d). 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). askresolves 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.
- A false answer is never consent. It comes either from a question already in flight, which
- both are returned with the others.
- The existing 15 tests pass against this change, run in the scratchpad against a patched copy. VERIFIED
- new option
public/file-panel.js, against round B (f76c680). There, the dirty units are the current tab when it carriesabsolutePath(a Touched editor) plus everytouchedStashesentry.saveUnsavedFileTabsaves an entry throughsaveTouchedStashand an editor throughhandleChangesSave, and returns a reason on refusal (f76c680:public/file-panel.js:570-616). The dialog isshowUnsavedEditsDialog(:618-698) and the check handler:151-158.preload.js:154already forwardsreason. VERIFIED (read)- The handler passes
{ cancellable: reason !== 'update' }toaskAboutUnsavedEdits, which passes it toshowUnsavedEditsDialog. - When not cancellable:
- no
unsaved-cancelbutton 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.
- no
- 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.contentfor a stash andreadChangesEditorContent(tab)for an editor. - The write goes through
window.api.writeRecoveryFile(filePath, text). - A new
#unsaved-recoveredline readsUnsaved 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.
- 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
- Round B's 8 tests, run against the patched copy of
f76c680:public/file-panel.js, still pass. VERIFIED
- The handler passes
recovery-file.jsexportswriteRecoveryFile({ dir, filePath, content, now = new Date(), fsImpl = fs, log })→{ ok: true, path }or{ ok: false, error }, and never throws. It also exportsRECOVERY_MAX_BYTES = 16 * 1024 * 1024andcreateRecoveryHandler({ 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' }.isAskingRestartis the installer's, true only in stagerestart-question; - refuses content over
maxBytes, counted in UTF-8 bytes:{ ok: false, error: 'larger than <maxBytes> bytes' }. The default isRECOVERY_MAX_BYTES, 16 MiB, kept inrecovery-file.jsand deliberately above the 2 MiB save cap. A Touched buffer that grew pastPANEL_FILE_MAX_BYTES(main.js:228) fails to save withcontent 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 andwarn[updater] unsaved text of <filePath> not kept: <error>on failure. That waymain.logkeeps the path after the relaunch. - Showing the paths again after the relaunch is out of scope.
dirispath.join(app.getPath('userData'), 'recovered'). On the deb install,userDatais~/.config/switchboard, the directory that holdsLocal Storagehere. VERIFIED (ls ~/.config/switchboard)- Under
SWITCHBOARD_DATA_DIR,userDatafollows it (main.js:27-29). VERIFIED diris created if missing (recursive) and thenchmoded to0700, so a pre-existingrecovered/with a wider mode is narrowed too. Thechmodis best-effort: if it is refused (for exampleEPERMon a filesystem without POSIX modes underSWITCHBOARD_DATA_DIR),log.warnrecords[updater] recovery directory <dir> keeps its mode: <code>and the file is still written, with mode0600.- The name is
<basename(filePath) cut to 200 UTF-8 bytes on a character boundary>-<ISO time with : and . replaced by ->, for examplenotes.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 outsidedirand never next to the original. An empty basename becomesuntitled. - The file is written with mode
0600and flagwx. OnEEXISTit tries-1,-2and 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 });andipcMain.handle('write-recovery-file', (_event, filePath, content) => recoveryHandler(filePath, content));go right after thesave-file-for-panelhandler (main.js:1020-1037), before// ── File Watching. That is afterconst 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 whenapp.isPackaged || FORCE_UPDATER. A reference from there to a laterconstis a TDZReferenceErrorin the released app alone, and neither lint nor the tests see it (review 5). - No
maxBytesis 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.
- refuses outside the restart question:
deb-update-install.jsaddsisAskingRestart()to whatcreateUpdateInstallerreturns:stage === 'restart-question'.public/app.jsupdaterHandler: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 itsflushStateForExit:function resumeStateAfterFailedInstall() { if (exitingAppTimer) { clearTimeout(exitingAppTimer); exitingAppTimer = null; } exitingApp = false; if (restoringWorkingSet) return _persistChain; return persistWorkingSet(); }. TherestoringWorkingSettest 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, andresumeStateAfterFailedInstallisfunction resumeStateAfterFailedInstall() {}, a no-op. Prototyped on82cf905: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 ofactivityFlushedForQuit(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 fromf76c680withfile-panel.jspatched;- 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'stest/dom-file-panel-unsaved-guard.test.jsplus 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.jsinproto/pr479/test/: its 4 exit-flush tests plus X1, X2 and X3; - 7 W, the source tests on
proto/main.patched.js(a copy of42a931d:main.jswith the wiring of this spec); - 17 in the existing
test/activitywatch-wiring.test.jsandtest/auto-update-setting.test.js, run against that patched copy throughproto/mirror/(symlinks to the repo,main.jsreplaced). 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 asnpx eslint main.jsat HEAD.- Against unpatched round B, R1, R3, R4 and R5 fail.
bash mut8.shran 95 mutations, and one more, S1k (the handler moved into the updater block), ran bypython3. None of the 96 survives, and each reddens the tests listed below (output inproto/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 testwithfail 0. ASSUMED until runNew file
test/deb-update-install.test.js, with these fakes:spawnthat writes one line intoopts.stdio[1]and returns anEventEmitter;execFileanswering a chosen version;pkexecPathpointing at an existing or a missing temp file;- real
fson a templogFile; - 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 leavesinstall()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 spawnM0 delete markQuitForUpdate()from the delegate path (also T6, T8)T2 a declined guard starts nothing confirmQuit→false →'declined', eventsconfirmonlyM1 replace if (!(await confirmQuit())) return 'declined'withawait 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.shellis 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 === falsebefore the child exits. The log file holds the child's line. Afterclose 0:query:/usr/bin/dpkg-query -W -f=${Version} switchboard, confirmRestart, mark, relaunch, quit. Theinfolog containsdeb 0.0.90 installedM3 no autoInstallOnAppQuit = false; M4 norelaunch(); M5 norevokeQuit(); M13 file dropped from argv; S1 no--disable-internal-agent; S5shell: true; S6 file interpolated into the script; S7 notrap; S8'pipe'stdio; S9 nodetached; S10 unsanitized env; S21 package nameSwitchboard; N1b noconfirmRestart(); N6DPKG_QUERY = 'dpkg-query'; N6c success log without the versionT4 cancel (126) 'cancelled'; events… spawn, status:cancelled, noquit/quitAndInstall. Status deep-equals{phase:'cancelled', message:<cancelled text>, durationMs:10000}.autoInstallOnAppQuitrestored to true. The warn hasexit 126and the log-file lineM6 drop the 126 mapping; S12 change the cancelled text; S13 drop message; S14 no restore; S20 no tail in the warnT5 127 and other codes 127 → 'not-authorized'and its text; 1 →'failed'and its text; flag restoredM7 drop the 127 mapping; S13; S14 T6 absolute paths PKEXEC === '/usr/bin/pkexec',DPKG_QUERY === '/usr/bin/dpkg-query'; a missingpkexecPath→'delegated',confirm, mark, quitAndInstallM8 drop the existsSynccheck; S11PKEXEC = 'pkexec'; N6T7 double click a second install()while pending →'busy', one childM9 drop the busy check T8 no recorded download delegates, no spawn M10 drop the download check T9 isDebInstalllinux+ DebUpdater+uid 1000 → true; other class, uid 0,win32→ falseM11 drop the root exclusion; M12 drop instanceofT10 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', endingconfirmRestart, mark, relaunch, quit, flag stays false, awarncontainingversion not verified, and nofailedstatusS15 skip the mismatch check; N6b treat a query error as a mismatch; S13; S14; M3; M4 T11 prerelease downloaded 0.1.0-beta.1, installed0.1.0~beta.1→'installed'S16 compare without the -→~mappingT12 the restart question cannot keep the old process confirmRestartresolving false → still'installed', endingconfirmRestart, mark, relaunch, quit, one childN1 if (!(await confirmRestart())) return 'deferred'; N1b; M4T13 hold from spawn to relaunch (guard + installer together) While pkexec is pending, guard.beforeQuit→ true and prevented, and windowcloseis prevented. Status:Installing the update… Answer or cancel the password dialog to continue.Afterclose 0, with thedpkg-queryanswer withheld, a quit is still prevented. Status:Finishing the update… Switchboard restarts in a moment.Nounsaved-checkis sent, noquithappens. When the injectedquit()runs,holdQuit()is already falseS19 holdQuitalways false; N4 release the hold at the child's exit; N4b keep the hold through the ownquit(); N5 change the password text; N5b one text for both stages; G2; G3T13c a quit while the restart question waits confirmRestartwithheld afterclose 0and the query →holdQuit()true,isAskingRestart()true, statusSave or discard your edits to restart., noquit; after the answerisAskingRestart()false. T3 also assertsisAskingRestart()false during the password promptP3 no restart-questionstage; P3b change its text; Q2disAskingRestarttrue in any stage (T3)T13b a failure releases the hold after close 126,holdQuit()→ falseN4c 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-getfirst on PATH, deb path…/s p'q"-…/it's a "deb" $(x);.deb. dpkg 0 → onlydpkg|-i|<path intact>|, status 0. dpkg 1 → dpkg thenapt-get|install|-f|-y|, status 0. apt 100 → status 100S2 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; S3Additions to existing tests:
Test Command / pass Mutation test/unsaved-guard.test.jsG1: afterconfirmQuityes and thenrevokeQuit(),beforeQuitreturns true, is prevented and sends a secondunsaved-check; a windowcloseis then held. VERIFIEDnode --test test/unsaved-guard.test.js→# fail 0G1 empty revokeQuittest/unsaved-guard.test.jsG2: withhold: () => true,beforeQuit→ true and prevented,closeprevented, nothing sent. VERIFIEDsame G2 drop the hold check in beforeQuit; G3 drop it inclosetest/unsaved-guard.test.jsG3: with areloadquestion in flight,confirmRestart(win)sends nothing until it is answered (with Cancel), then sendsunsaved-checkwith reason'update'; on its answer it resolves true and the nextbeforeQuitreturns false. VERIFIEDsame G5 reason 'quit'; G6 noquitApproved = true; P4 ignore the answertest/unsaved-guard.test.jsG4:confirmRestartsends'update'. Answered false → a second'update'check, andbeforeQuitstill holds. Answered true → resolves true, andbeforeQuitreturns false. VERIFIEDsame P4 ignore the answer ( await ask(...); break;); G5test/dom-file-panel-unsaved-guard.test.js(round B's fixtures:dirtyTabopens a Touched editor throughonMcpOpenFile({filePath});openDiffTabstashes it). R1: dirty editor, reason'update'→ no#unsaved-cancel, the update hint, Escape leaves it open with no answer, Discard answers{proceed:true}. VERIFIEDnode --test test/dom-file-panel-unsaved-guard.test.js→# fail 0R1 reason ignored; R3 Escape always cancels; R4 Cancel always created; R5 hint text changed R2: reason 'quit'→#unsaved-cancelpresent, Escape answers{proceed:false}. VERIFIEDsame R2 cancellable: falsefor every reasonR3: dirty editor made stale, reason 'update', Save → error shown;writeRecoveryFilecalled once with('/repo/a.md', 'x0\nmine');#unsaved-recoveredreadsUnsaved text kept in: /data/recovered/a.md-T. Discard restarts Switchboard.; no answer yet; Discard enabled →{proceed:true}. VERIFIEDsame P2 no recovery write; P2c text changed; P2d empty text written; R1 R4: the same failed save under 'quit'→writeRecoveryFilenot called,#unsaved-recoveredhidden. VERIFIEDsame 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 →writeRecoveryFilecalled once with the full text, and the path shown. VERIFIEDsame P2; R1 R5: two stashes (A in s1, C in s2), A made stale, reason 'update', Save → C saved to disk,writeRecoveryFilecalled once, with('/repo/a.md', 'x0\none'), the path shown, Discard →{proceed:true}. VERIFIEDsame P2e stash text read from the editor instead of entry.content; P2; P2dNew test/recovery-file.test.jsF1:/home/u/proj/notes.mdat2026-10-05T12:30:01.234Z→<dir>/notes.md-2026-10-05T12-30-01-234Z, content intact, file mode0600, dir mode0700. VERIFIEDnode --test test/recovery-file.test.js→# fail 0F1 file mode 0644; Q3 nochmod; F2b full path instead of the basenameF1b: a pre-existing recovered/at0755is0700after a write. VERIFIEDsame Q3 no chmodF2: ../../etc/xlands indir; a secondxat the same instant gets-1and the first keeps its content. VERIFIEDsame F2 flag w(overwrite); F2bF3: a parent that is a regular file → {ok:false, error:<string>}, no throw;content: null→{ok:false, error:'no text to keep'}. VERIFIEDsame 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. VERIFIEDsame Q1 budget 300; Q1b no truncation F5: handler with isAskingRestart: () => false→{ok:false, error:'only while an update restart is asked'},dirnot created. VERIFIEDsame 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 withlarger than 16777216 bytes. VERIFIEDsame S2x cap = 2 MiB ( PANEL_FILE_MAX_BYTES); Q2b no cap; Q2c cap counted in charactersF8: an fsImplwhosechmodSyncthrowsEPERM→{ok:true}, the text written with mode0600, and one warn[updater] recovery directory <dir> keeps its mode: EPERM. VERIFIEDsame S3x chmodnot caught; S3y caught without the warn; Q3F7: a successful handler call logs exactly info [updater] unsaved text kept in <dir>/notes.md-2026-10-05T12-30-01-234Z. VERIFIEDsame Q4 no log line test/unsaved-guard.test.jsG5: a renderer that answers no at once (on the nextsetImmediate) gets 2–4'update'checks in 600 ms. VERIFIEDnode --test test/unsaved-guard.test.js→# fail 0Q5 no pause; Q5b restartRetryMs = 0test/exit-flush.test.js(#479's file) X1: opena,flushStateForExit(), openb, schedule (dropped), thenresumeStateAfterFailedInstall()→ writes[['a'], ['a','b']], grace timer cleared; openc, schedule, run timers → last write['a','b','c']. VERIFIED on82cf905node --test test/exit-flush.test.js→# fail 0Q6 exitingAppleft true; Q6b nopersistWorkingSet()X3: flushStateForExit(), thenrestoringWorkingSet = true, thenresumeStateAfterFailedInstall()→ no write beyond the first,exitingAppfalse. VERIFIED on82cf905node --test test/exit-flush.test.js→# fail 0S4x drop the restoringWorkingSetguardX2 (source test on public/app.js): theinstall-statuscase callssetUpdaterStatus(data.message, data.durationMs)andresumeStateAfterFailedInstall()oncancelled,not-authorizedandfailed. VERIFIED on82cf905same Q6c call removed; Q6d cancelledleft outtest/activitywatch-wiring.test.js:66-70, "an update install is never held", rewritten.main.jspassesmarkQuitForUpdate: () => { activityFlushedForQuit = true; }, anddeb-update-install.jscallsmarkQuitForUpdate()beforeupdater.quitAndInstall()in the delegate path and beforequit()in the restart path (source order). Today's version fails by construction oncequitAndInstallleaves the handler. VERIFIED (rewritten test run onproto/mirror/)node --test test/activitywatch-wiring.test.js→# fail 0S1l markQuitForUpdate: () => {}(also W3)New test/updater-wiring.test.js, source tests onmain.js(W1–W7, prototyped asproto/wiring.test.js). VERIFIED onproto/main.patched.js; all 7 fail on42a931d:main.jsnode --test test/updater-wiring.test.js→# fail 0per row below W1 const { execFile, spawn } = require('child_process');S1a drop spawnW2 let updateInstaller = null;precedescreateUnsavedGuard(, built withhold: () => (updateInstaller ? updateInstaller.holdQuit() : false)S1b hold: () => falseW3 in updateInstaller = createUpdateInstaller({…}):confirmQuit→unsavedGuard.confirmQuit(mainWindow),revokeQuit→unsavedGuard.revokeQuit(),confirmRestart→unsavedGuard.confirmRestart(mainWindow),markQuitForUpdatesetsactivityFlushedForQuit,DebUpdater: require('electron-updater').DebUpdaterS1c confirmQuitalways true; S1drevokeQuitno-op; S1econfirmRestartalways true; S1l flag not setW4 the update-downloadedlistener callsupdateInstaller.setDownloaded(info)S1f delete the call W5 updater-installreturnsupdateInstaller.install()and holds noquitAndInstallS1g call autoUpdater.quitAndInstall()thereW6 createRecoveryHandler({…})hasdir: path.join(app.getPath('userData'), 'recovered')and theisAskingRestartclosure, passes nomaxBytes, andwrite-recovery-filedelegates to itS1h gate always open; S1i maxBytes: PANEL_FILE_MAX_BYTES; S1jdir: os.tmpdir()W7 the createRecoveryHandler({call comes afterconst PANEL_FILE_MAX_BYTESand after the end of theif (app.isPackaged || process.env.FORCE_UPDATER) {blockS1k move the handler and its ipcMain.handleinto the updater blockSource test on public/app.js:updaterHandlerhascase 'install-status':callingsetUpdaterStatus(data.message, data.durationMs). Covered by X2. VERIFIED on82cf905→ # fail 0delete the case (X2) No existing test covers the updater install:
grep -rln "quitAndInstall\|updater-install" test/→ onlytest/activitywatch-wiring.test.js(flag order) andtest/unsaved-guard.test.js:219(guard semantics);test/auto-update-setting.test.jscovers the setting gates. VERIFIEDManual 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.loghas no line between09:38:14.789(pkexec) and09:38:34.237, which is 19.4 s against mutter's 5000 mscheck-alive-timeout. VERIFIED. After the fix,[updater],[status]and PTY lines keep appearing inmain.logduring the prompt.- 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 runnpm run bundle:codemirror && npx electron-builder --linux deb --config.npmRebuild=false. Do not commit the version change. - 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). - 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 readsInstalling 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 -3shows the pkexec line anddeb install cancelled (exit 126).
- Open a file panel and make an unsaved edit, then use ☰ Quit. Pass: the unsaved-edits question appears. Cancel it.
- 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 444the 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 showsSave 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 mode600(stat -c %a). Press Discard. Pass if all of these hold:- no "not responding" dialog;
- no renderer crash,
render-process-goneor worker error inmain.logbetweendeb install:and the relaunch; - the app relaunches, Settings → Version reads 0.0.89, and
dpkg -l switchboardreports0.0.89; main.logshowsdeb 0.0.89 installed(orversion not verified), and at quitUpdate will not be installed on quit because autoInstallOnAppQuit is set to false, not a second install;~/.config/switchboard/logs/deb-install.logholds dpkg'sUnpacking/Setting uplines.
5 — Constraints the implementer inherits
Copied from
.ai/shared-guidelines.md(VERIFIED, read at HEAD):- "NEVER run
npm run build:linux(ortask 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 devnode_modules, the manual check builds with--config.npmRebuild=false. - "Use
SWITCHBOARD_DATA_DIRisolation 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 (autoUpdateris null unless packaged orFORCE_UPDATER,main.js:109;package-typeexists only under a packagedresourcesPath). 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
## UnreleasedinCHANGELOG.md" ; "task check(lint + test). 0 errors." ; "NoCo-Authored-By. Imperative subject, brief why-body." ; "gh pr createagainstdevsuitup/switchboard:main… Title format:(area): short imperative." ; requestdevsuitupas reviewer when ready. - "Default value for a setting |
SETTING_DEFAULTSinsetting-defaults.js— never a literal at the call site" (no new setting is specified). - Renderer tests: "
installSpies: falseis required when the eval defines functions you also spy on" (only if a jsdom test ofapp.jsis chosen over the source test).
Service conventions (VERIFIED): no
pyproject.toml— this is Node.package.jsonscripts.test=node scripts/run-tests.js(stage 1 at 4 workers, everytest/*.js),scripts.lint=eslint .;Taskfile.yaml:90-92check: deps: [test, lint].build.filesincludes*.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.:219install semantics),test/activitywatch-wiring.test.js(:58hold once,:66install never held — to be rewritten,:82error resets the flag),test/auto-update-setting.test.js(autoInstallOnAppQuit = autoUpdateassignment atmain.js:3294must stay as is).Docs to update:
docs/settings.md:59-69(Updates): one paragraph — on a.debinstall, Restart asks for the administrator password with Switchboard still open and unable to quit until the install ends; dpkg's output is indeb-install.lognext tomain.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.mdhas no install section (only:68-71, on manifests) — no change. VERIFIED.ai/contexts/viewer-panel.md:170:updater-installasks 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/;holdkeeps quit and close from going through from the spawn to the relaunch..ai/contexts/activitywatch.md:217-223"Quitting": the flag is set bymarkQuitForUpdate, just beforequitAndInstall()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-eventgainsinstall-status; new invoke channelwrite-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, ordocs/decisions/— not in code comments. VERIFIED (grep)
CHANGELOG entry, under
## Unreleased→### Fixed(rule ofdocs/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.jsis about 60 lines. The change adds about 25 lines tomain.js, 15 tounsaved-guard.js, about 35 topublic/file-panel.js, 1 topreload.jsand about 10 topublic/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. ASSUMED7 — 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 -yfallback) is settled yes.
- Repository:
Symptom
On Ubuntu with the
.debinstall, 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) callsautoUpdater.quitAndInstall()with the windows still mapped..deb, electron-updater 6.8.9 installs before quitting.BaseUpdater.quitAndInstallrunsinstall(), thenapp.quit().DebUpdater.doInstallrunsdpkg -ithroughLinuxUpdater.runCommandWithSudoIfNeeded, which wraps it inpkexec(the first ofgksudo,kdesudo,pkexec,beesufound) viaspawnSyncLog, a synchronous spawn (node_modules/electron-updater/out/LinuxUpdater.js:34-51,DebUpdater.js).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
quitAndInstallon Linux. The blocked process then has no window to ping. This is the smallest change. Measure whether a hidden window is still pinged.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:
unsavedGuard.confirmQuit) still runs before anything is hidden.pkexecexit 126) leaves the app running with its windows back. Today electron-updater dispatches an error in that case and the app stays open.