From dfa54afb739295d828b0eccca0743dc16d19b1c8 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 23 May 2026 23:48:18 +0200 Subject: [PATCH] feat(main): requestSingleInstanceLock to prevent PTY loss on AppImage replace On 2026-05-23, replacing the AppImage while Switchboard had active node-pty sessions killed those sessions. The OS spawned the new binary which initialised a second Electron process; the two instances raced and the running PTYs were orphaned/killed. Electron's requestSingleInstanceLock() is the standard fix: - The first instance acquires the lock and continues normally. - Any subsequent launch (e.g. the new AppImage binary after an in-place replace) fails to acquire the lock, calls app.quit() immediately, and exits without touching any PTY. - A 'second-instance' listener on the first instance brings its main window to the front, so the user gets visual confirmation the app is still running. Changes: - main.js: call app.requestSingleInstanceLock() before app.whenReady() - Wrap app.whenReady() and all init code in the else-branch so it only runs for the true first instance - Register app.on('second-instance') to restore/focus mainWindow --- main.js | 113 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/main.js b/main.js index 2c587b7..870cdb5 100644 --- a/main.js +++ b/main.js @@ -1378,60 +1378,79 @@ ipcMain.handle('updater-install', () => { }); // --- App lifecycle --- -app.whenReady().then(() => { - buildMenu(); - createWindow(); - startProjectsWatcher(); - scheduleIpc.ensureScheduleCreatorCommand(); - - // Shared runCommand for both cron scheduler and manual "run now" - const { spawn: cpSpawn } = require('child_process'); - function runScheduleCommand(cmd, cwd, name, onDone) { - const globalSettings = getSetting('global') || {}; - const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; - const profile = resolveShell(profileId); - const shell = profile.path; - const args = shellArgs(shell, cmd, profile.args || []); - - log.info(`[schedule] Running: ${shell} ${args.join(' ')}`); - const child = cpSpawn(shell, args, { - cwd, - stdio: ['ignore', 'ignore', 'pipe'], - env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, - }); +// Prevent a second Electron instance from killing active PTY sessions. +// This happens when the user replaces the AppImage while Switchboard is running: +// the OS spawns the new binary, which would otherwise initialise a second process +// and leave the first one's node-pty sessions orphaned or killed. +// requestSingleInstanceLock ensures only one instance runs at a time. The second +// launch quits immediately; the first brings its window to the front. +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + app.quit(); +} else { + // Focus the existing window when a second launch is attempted. + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); - let stderr = ''; - child.stderr.on('data', (data) => { stderr += data.toString(); }); + app.whenReady().then(() => { + buildMenu(); + createWindow(); + startProjectsWatcher(); + scheduleIpc.ensureScheduleCreatorCommand(); + + // Shared runCommand for both cron scheduler and manual "run now" + const { spawn: cpSpawn } = require('child_process'); + function runScheduleCommand(cmd, cwd, name, onDone) { + const globalSettings = getSetting('global') || {}; + const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; + const profile = resolveShell(profileId); + const shell = profile.path; + const args = shellArgs(shell, cmd, profile.args || []); + + log.info(`[schedule] Running: ${shell} ${args.join(' ')}`); + const child = cpSpawn(shell, args, { + cwd, + stdio: ['ignore', 'ignore', 'pipe'], + env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, + }); - child.on('exit', (code) => { - if (stderr.trim()) log.error(`[schedule] ${name} stderr:\n${stderr.trim()}`); - log.info(`[schedule] ${name} finished (exit ${code})`); - if (onDone) onDone(); - }); + let stderr = ''; + child.stderr.on('data', (data) => { stderr += data.toString(); }); - child.on('error', (err) => { - log.error(`[schedule] ${name} error:`, err.message); - if (onDone) onDone(); - }); - } + child.on('exit', (code) => { + if (stderr.trim()) log.error(`[schedule] ${name} stderr:\n${stderr.trim()}`); + log.info(`[schedule] ${name} finished (exit ${code})`); + if (onDone) onDone(); + }); - scheduleIpc.init(log, runScheduleCommand); - startScheduler(log, runScheduleCommand); + child.on('error', (err) => { + log.error(`[schedule] ${name} error:`, err.message); + if (onDone) onDone(); + }); + } - // Re-index search if FTS table was recreated (e.g. tokenizer config change) - if (searchFtsRecreated) populateCacheViaWorker(); + scheduleIpc.init(log, runScheduleCommand); + startScheduler(log, runScheduleCommand); - // Check for updates after launch - if (autoUpdater) { - setTimeout(() => autoUpdater.checkForUpdates().catch(e => log.error('[updater] check failed:', e?.message || String(e))), 5000); - // Re-check every 4 hours for long-running sessions - setInterval(() => autoUpdater.checkForUpdates().catch(e => log.error('[updater] check failed:', e?.message || String(e))), 4 * 60 * 60 * 1000); - } + // Re-index search if FTS table was recreated (e.g. tokenizer config change) + if (searchFtsRecreated) populateCacheViaWorker(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); - }); -}); + // Check for updates after launch + if (autoUpdater) { + setTimeout(() => autoUpdater.checkForUpdates().catch(e => log.error('[updater] check failed:', e?.message || String(e))), 5000); + // Re-check every 4 hours for long-running sessions + setInterval(() => autoUpdater.checkForUpdates().catch(e => log.error('[updater] check failed:', e?.message || String(e))), 4 * 60 * 60 * 1000); + } + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + }); // end app.whenReady +} // end gotSingleInstanceLock else-branch app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit();