Raised by CodeRabbit review:
highperformancecoder replied on May 26, 2026
@highperformancecoder
highperformancecoder
on May 26, 2026
OwnerAuthor
How do menu handlers handle asynchronous rejections?
coderabbitai[bot] replied on May 26, 2026
@coderabbitai[bot]
coderabbitai[bot]
on May 26, 2026
Contributor
🧩 Analysis chain
@highperformancecoder — They don't, at least not at the menu level. Here's what the actual wiring looks like in ApplicationMenuManager:
{
label: 'Open All Godley Tables',
click() { CommandsManager.openAllGodleyTables(); }, // synchronous click, Promise dropped
},
The click function is synchronous — it calls an async function but neither awaits nor .catch()-es the returned Promise. That means:
Any rejection from openAllGodleyTables() (or from openGodleyTable inside it) immediately becomes an unhandled Promise rejection.
There is no global unhandledRejection / uncaughtException handler found anywhere in the TypeScript code to catch it as a fallback.
In Electron ≥14 / Node.js ≥15 the process-level default for unhandled rejections is to terminate the process (though Electron may demote it to a warning in practice).
So my original comment about awaiting openGodleyTable(id) inside the loop is still relevant (it prevents racing opens and surfaces errors to the caller), but the more complete fix is at the menu-handler level too:
// Option A: propagate errors sequentially
{
label: 'Open All Godley Tables',
async click() {
try {
await CommandsManager.openAllGodleyTables();
} catch (err) {
console.error('openAllGodleyTables failed', err);
}
},
},
// Option B: open concurrently and catch per-table
static async openAllGodleyTables() {
const ids = await minsky.allGodleyTables();
await Promise.allSettled(ids.map(id => this.openGodleyTable(id)));
}
Promise.allSettled is particularly handy here because it lets all opens run concurrently and collects both successes and failures without short-circuiting, matching the "best-effort bulk open" intent.
That said, looking at the broader codebase, several other menu items follow the same fire-and-forget pattern (e.g. upgrade(), randomLayout()), so this is a pre-existing pattern rather than something introduced by this PR. Whether to address it consistently across all handlers is a separate question, but for the new bulk-open items it would be worth being explicit since they involve network-style async window creation.
Anonymous
Also comments by Gemini:
What are the issues of not awaiting asynchronous functions called from menu items in electron apps?
In Electron, application menus (Menu and MenuItem) are managed exclusively by the Main Process. When you assign an asynchronous click handler to a menu item, the underlying Electron framework triggers that callback synchronously.
If you do not explicitly handle or await asynchronous logic within those menu item actions (or fail to handle the floating promises), you run into several subtle but critical architecture, state, and stability bugs.
The Electron menu item click property expects a standard synchronous callback function: click: (menuItem, browserWindow, event) => void.
If you pass an async function or call an async function inside it without a proper .catch() block or internal try/catch, it results in a floating promise. If that promise rejects (e.g., a file save dialog is canceled, a network call fails, or an IPC invocation throws an error), it triggers an Unhandled Promise Rejection. Depending on your configuration and Node version, this can silently swallow critical errors or abruptly crash the entire Main Process, shutting down your app.
JavaScript
// ❌ Dangerous: If
performSavefails, the error goes unhandled.const menuItem = new MenuItem({
label: 'Save',
click: async () => {
await performSave(); // No try/catch around this await!
}
});
When a user clicks a menu item, they expect the app's state to match the visual feedback. Because the menu callback doesn't block or wait for completion, it fires the async function and finishes instantly.
If the user clicks the menu item multiple times rapidly, it will fire multiple concurrent instances of your asynchronous task. This can lead to severe race conditions:
If a menu item triggers a long-running asynchronous operation (like "Export Database") and the user immediately hits Cmd+Q (macOS) or clicks "Quit" from the menu, Electron will attempt to close the application.
Because the menu action didn't flag that it was still "busy" (since it wasn't tracked or awaited by a lifecycle controller), Electron won't know it needs to wait. The Main process might terminate abruptly mid-operation, resulting in data corruption or partial file writes.
A common misconception is that async/await inherently moves heavy execution to another thread. In Node.js, async code still runs on the single-threaded event loop of the Main process.
If your un-awaited menu callback kicks off heavy CPU-bound asynchronous tasks (like parsing massive JSON bundles, processing images, or synchronous crypto operations right after an await barrier), it will block the Main process event loop. This causes the entire application UI to stutter or freeze, stopping the Renderer process from handling window resizing, minimizing, or rendering animations.
Best Practices to Fix This
To avoid these pitfalls, follow these patterns when dealing with async logic in Electron menus:
A. Always Wrap in try/catch
Ensure that any error occurring inside the async chain is handled gracefully without crashing the main process.
JavaScript
const menuItem = new MenuItem({
label: 'Export Data',
click: async (menuItem, browserWindow) => {
try {
await exportDataLogic();
} catch (error) {
console.error('Export failed:', error);
// Safely alert the user via dialog
dialog.showErrorBox('Export Error', 'Failed to export your data.');
}
}
});
B. Implement a Debounce/Loading State
Prevent the user from triggering the action multiple times by dynamically disabling the menu item or the UI.
JavaScript
let isExporting = false;
const menuItem = new MenuItem({
label: 'Export Data',
click: async (menuItem) => {
if (isExporting) return;
}
});
C. Offload to the Renderer Process via IPC
Menus live in the Main process, but the state usually lives in the Renderer (the frontend). Instead of doing heavy processing in the menu click handler itself, use the click handler purely to emit an IPC message to your frontend, where you can easily manage loading spinners, UI disabling, and local error notifications.
JavaScript
// Main Process
const menuItem = new MenuItem({
label: 'Open Settings',
click: (menuItem, browserWindow) => {
if (browserWindow) {
// Simply signal the frontend and let it manage the async flow
browserWindow.webContents.send('menu-open-settings');
}
}
});