fix(electron): prevent app hang on macOS native quit (Cmd+Q, Dock > Quit)

On macOS, native quit fires before-quit BEFORE window close events. The
before-quit handler was calling ipcMain.removeAllListeners(), which destroyed
the BEFORE_CLOSE_DONE IPC listener. The close handler then sent NOTIFY_ON_CLOSE
to the renderer (for sync/finish-day callbacks), the renderer responded with
BEFORE_CLOSE_DONE, but the listener was gone — causing a permanent hang.

Fix: intercept before-quit when isQuiting=false and delegate to win.close(),
which re-enters the existing close handler and runs before-close callbacks
normally. Move ipcMain.removeAllListeners() to will-quit, which fires after
all windows are closed and all IPC flows are guaranteed complete.

Also improves close event log to include pending before-close handler IDs.
This commit is contained in:
Johannes Millan 2026-04-15 16:14:51 +02:00
parent a4fe03272a
commit 5ce78a5b63
2 changed files with 26 additions and 12 deletions

View file

@ -455,7 +455,7 @@ const appCloseHandler = (app: App): void => {
mainWin.on('close', (event) => {
// NOTE: this might not work if we run a second instance of the app
log('close, isQuiting:', getIsQuiting());
log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids);
if (!getIsQuiting()) {
event.preventDefault();
if (getIsMinimizeToTray()) {

View file

@ -21,14 +21,14 @@ import { CONFIG } from './CONFIG';
import { lazySetInterval } from './shared-with-frontend/lazy-set-interval';
import { initIndicator } from './indicator';
import { quitApp, showOrFocus } from './various-shared';
import { createWindow } from './main-window';
import { createWindow, getWin } from './main-window';
import { IdleTimeHandler } from './idle-time-handler';
import { destroyTaskWidget } from './task-widget/task-widget';
import {
initializeProtocolHandling,
processPendingProtocolUrls,
} from './protocol-handler';
import { getIsQuiting, setIsLocked } from './shared-state';
import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state';
const ICONS_FOLDER = __dirname + '/assets/icons/';
const IS_MAC = process.platform === 'darwin';
@ -299,18 +299,32 @@ export const startApp = (): void => {
appIN.on('will-quit', () => {
// un-register all shortcuts.
globalShortcut.unregisterAll();
// Safe to remove IPC listeners here: all windows are closed and before-close
// IPC flows (sync, finish-day) are guaranteed to have completed.
ipcMain.removeAllListeners();
});
appIN.on('before-quit', () => {
log('App before-quit: cleaning up resources');
// Clean up task widget before quitting
appIN.on('before-quit', (event) => {
log('App before-quit: isQuiting=', getIsQuiting());
if (!getIsQuiting()) {
// Native quit path (Cmd+Q, Dock > Quit on macOS): app.quit() was called
// by the OS without going through quitApp(), so isQuiting was never set.
// Prevent the immediate quit and delegate to the window close handler,
// which manages the before-close callback flow (sync, finish-day, etc.)
// and sets isQuiting=true before re-quitting.
event.preventDefault();
const win = getWin();
if (win && !win.isDestroyed()) {
win.close();
} else {
// No window to close — set flag and re-trigger quit directly.
setIsQuiting(true);
app.quit();
}
return;
}
// isQuiting=true: all before-close IPC work is complete — safe to clean up.
destroyTaskWidget();
// Remove all IPC listeners to prevent memory leaks
ipcMain.removeAllListeners();
// Clear any pending timeouts/intervals
if (global.gc) {
global.gc();
}