From e6d21852e4061d3fa36d23a18b244014f8282fd2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 09:45:25 -0600 Subject: [PATCH 1/2] fix: harden fire-and-forget promise rejection handling Attach .catch (or try/catch) on void.then and rejectable IPC voids so unhandledrejection no longer slips past ignoreVoid, and make no-floating-promises explicit with checkThenables. --- eslint.config.mjs | 6 + src/main/index.ts | 255 ++++++++++-------- src/renderer/components/ChatComposer.tsx | 4 +- src/renderer/components/ChatPanel.tsx | 9 +- src/renderer/components/ConnectionPanel.tsx | 8 +- src/renderer/components/LanguageSelector.tsx | 22 +- .../MeshcoreRepeaterPasswordControls.tsx | 33 ++- src/renderer/components/NodeDetailModal.tsx | 23 +- src/renderer/components/NomadNetworkPanel.tsx | 7 +- .../ReticulumPropagationControls.tsx | 13 +- .../ReticulumPropagationSection.tsx | 39 ++- .../flasher/FirmwareDownloadLinks.tsx | 15 +- src/renderer/hooks/useAppStartupDbPrune.ts | 13 +- .../useMeshcoreRepeaterRemoteAuth.test.tsx | 12 +- .../hooks/useMeshcoreRoomAuth.test.tsx | 6 +- ...eshcoreRuntime.repeater-admin-rpc.test.tsx | 10 +- src/renderer/hooks/useNodeStatusNotifier.ts | 10 +- .../hooks/useRrcStartupAutoConnect.ts | 11 +- src/renderer/lib/appSettingsStorage.ts | 8 +- src/renderer/lib/flasher/rnode.test.ts | 10 +- src/renderer/lib/networkDiscovery.ts | 23 +- .../lib/reticulum/reticulumRmapDiscovery.ts | 24 +- src/renderer/main.tsx | 25 +- src/renderer/runtime/useMeshtasticRuntime.ts | 48 +++- 24 files changed, 422 insertions(+), 212 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f5c57a834..d861a059c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -95,6 +95,12 @@ export default tseslint.config( 'error', { checksVoidReturn: { attributes: false } }, ], + // Explicit: already error via strictTypeChecked. ignoreVoid keeps intentional fire-and-forget + // (call sites must still attach .catch when the promise can reject). + '@typescript-eslint/no-floating-promises': [ + 'error', + { ignoreVoid: true, checkThenables: true }, + ], // Off globally: defensive UI ?. / ?? churn. Re-enabled for shared + renderer/lib below. '@typescript-eslint/no-unnecessary-condition': 'off', // Autofix removes generics that TypeScript still needs for inference (tsc errors after diff --git a/src/main/index.ts b/src/main/index.ts index 314ef4d62..044e0ca3f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2393,11 +2393,21 @@ ipcMain.handle('bluetooth-pair', async (event, macAddress: unknown, pin: unknown stderr.includes('AuthenticationCanceled'); const pairingSucceededByOutput = stdout.includes('Pairing successful'); if (!pairingFailedByOutput && (pairingSucceededByOutput || code === 0)) { - void trustDeviceBestEffort().then(() => { - if (settled) return; - console.debug('[IPC] bluetooth-pair success'); - finishResolve(); - }); + void trustDeviceBestEffort() + .then(() => { + if (settled) return; + console.debug('[IPC] bluetooth-pair success'); + finishResolve(); + }) + .catch((e: unknown) => { + // trustDeviceBestEffort is best-effort and should not reject; finish pairing anyway. + console.debug( + '[IPC] bluetooth-pair trust settle:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + if (settled) return; + finishResolve(); + }); } else { console.warn( '[IPC] bluetooth-pair failed:', @@ -6405,134 +6415,147 @@ app.on('child-process-gone', (_event, details) => { ); }); -void app.whenReady().then(() => { - try { - initLogFile(); - console.debug(`[Startup] runtime ${formatRuntimeLogTag()}`); +void app + .whenReady() + .then(() => { try { - console.debug('[main] crashDumps path:', sanitizeLogMessage(app.getPath('crashDumps'))); - } catch (e: unknown) { - console.warn( - '[main] crashDumps path unavailable:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } + initLogFile(); + console.debug(`[Startup] runtime ${formatRuntimeLogTag()}`); + try { + console.debug('[main] crashDumps path:', sanitizeLogMessage(app.getPath('crashDumps'))); + } catch (e: unknown) { + console.warn( + '[main] crashDumps path unavailable:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + } - // Register lxm:// deep links (dev + packaged). OS-specific: argv in defaultApp. - if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient('lxm', process.execPath, [path.resolve(process.argv[1])]); + // Register lxm:// deep links (dev + packaged). OS-specific: argv in defaultApp. + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('lxm', process.execPath, [path.resolve(process.argv[1])]); + } + } else { + app.setAsDefaultProtocolClient('lxm'); } - } else { - app.setAsDefaultProtocolClient('lxm'); - } - const coldStartUrl = findLxmUrlInArgv(process.argv); - if (coldStartUrl) pendingOpenUrl = coldStartUrl; + const coldStartUrl = findLxmUrlInArgv(process.argv); + if (coldStartUrl) pendingOpenUrl = coldStartUrl; - initDatabase(); + initDatabase(); - // Auto-restore TAK server if auto-start is enabled - const takSettingsPath = path.join(app.getPath('userData'), 'tak-settings.json'); - try { - if (fs.existsSync(takSettingsPath)) { - const raw: unknown = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); - // Backfill autoStart for settings files saved before the field was added. - if ( - raw != null && - typeof raw === 'object' && - typeof (raw as Record).autoStart !== 'boolean' - ) { - (raw as Record).autoStart = false; - } - const saved = raw; - validateTakSettings(saved); - if (saved.autoStart) { - void ensureTakServerManager() - .then((m) => m.start(saved)) - .catch((e: unknown) => { - console.error( - '[TAK] Auto-start failed:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - }); + // Auto-restore TAK server if auto-start is enabled + const takSettingsPath = path.join(app.getPath('userData'), 'tak-settings.json'); + try { + if (fs.existsSync(takSettingsPath)) { + const raw: unknown = JSON.parse(fs.readFileSync(takSettingsPath, 'utf-8')); + // Backfill autoStart for settings files saved before the field was added. + if ( + raw != null && + typeof raw === 'object' && + typeof (raw as Record).autoStart !== 'boolean' + ) { + (raw as Record).autoStart = false; + } + const saved = raw; + validateTakSettings(saved); + if (saved.autoStart) { + void ensureTakServerManager() + .then((m) => m.start(saved)) + .catch((e: unknown) => { + console.error( + '[TAK] Auto-start failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); + } } + } catch (e: unknown) { + console.warn( + '[TAK] Settings restore failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); } - } catch (e: unknown) { - console.warn( - '[TAK] Settings restore failed:', - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } - // Force the dock icon in development on macOS - if (!app.isPackaged && process.platform === 'darwin') { - const iconPath = path.join( - __dirname, - '../../resources/icons/mac/iconset/icon_256x256@1x.png', + // Force the dock icon in development on macOS + if (!app.isPackaged && process.platform === 'darwin') { + const iconPath = path.join( + __dirname, + '../../resources/icons/mac/iconset/icon_256x256@1x.png', + ); + app.dock?.setIcon(iconPath); + } + createWindow(); + + const MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS = 60 * 60 * 1000; + const MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC = 24 * 60 * 60; + setInterval(() => { + if (process.uptime() < MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC) return; + const uptimeSec = Math.floor(process.uptime()); + const mem = process.memoryUsage(); + const ble = nobleBleManager.getLongSessionHealthSnapshot(); + console.debug( + `[main] long-session health uptimeSec=${uptimeSec} rss=${mem.rss} heapUsed=${mem.heapUsed} ble=${JSON.stringify(ble)}`, + ); + }, MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS).unref(); + + setupAppMenu(); + + // ─── Power monitor: notify renderer on suspend/resume ────────── + powerMonitor.on('suspend', () => { + console.debug('[main] System suspending'); + rendererHeartbeatWatchdog.clearResumeWatchdog(); + mqttManager.handlePowerSuspend(); + meshcoreMqttAdapter.handlePowerSuspend(); + mainWindow?.webContents.send('power:suspend'); + }); + powerMonitor.on('resume', () => { + console.debug('[main] System resumed'); + rendererHeartbeatWatchdog.startResumeWatchdog(); + mainWindow?.webContents.send('power:resume'); + }); + } catch (error) { + console.error( + '[main] Fatal startup error:', + sanitizeLogMessage(error instanceof Error ? (error.stack ?? error.message) : String(error)), ); - app.dock?.setIcon(iconPath); + const isNativeModuleError = + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_DLOPEN_FAILED'; + const message = isDatabaseSchemaTooNewError(error) + ? formatDatabaseSchemaTooNewMessage(error) + : isNativeModuleError + ? `A native module failed to load. This usually means the app needs to be rebuilt for this version of Electron.\n\nFix: run "pnpm install" in the project directory, then restart.\n\nDetails: ${error.message}` + : `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`; + showFatalStartupError('Mesh-Client — Startup Error', message); + app.quit(); + return; } - createWindow(); - - const MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS = 60 * 60 * 1000; - const MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC = 24 * 60 * 60; - setInterval(() => { - if (process.uptime() < MAIN_PROCESS_HEALTH_UPTIME_THRESHOLD_SEC) return; - const uptimeSec = Math.floor(process.uptime()); - const mem = process.memoryUsage(); - const ble = nobleBleManager.getLongSessionHealthSnapshot(); - console.debug( - `[main] long-session health uptimeSec=${uptimeSec} rss=${mem.rss} heapUsed=${mem.heapUsed} ble=${JSON.stringify(ble)}`, - ); - }, MAIN_PROCESS_HEALTH_LOG_INTERVAL_MS).unref(); - setupAppMenu(); - - // ─── Power monitor: notify renderer on suspend/resume ────────── - powerMonitor.on('suspend', () => { - console.debug('[main] System suspending'); - rendererHeartbeatWatchdog.clearResumeWatchdog(); - mqttManager.handlePowerSuspend(); - meshcoreMqttAdapter.handlePowerSuspend(); - mainWindow?.webContents.send('power:suspend'); - }); - powerMonitor.on('resume', () => { - console.debug('[main] System resumed'); - rendererHeartbeatWatchdog.startResumeWatchdog(); - mainWindow?.webContents.send('power:resume'); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + try { + createWindow(); + } catch (error) { + console.error( + '[main] Window creation error:', + sanitizeLogMessage(error instanceof Error ? error.message : String(error)), + ); + } + } else { + mainWindow?.show(); // Restore hidden window on dock click + } }); - } catch (error) { + }) + .catch((error: unknown) => { console.error( - '[main] Fatal startup error:', + '[main] app.whenReady failed:', sanitizeLogMessage(error instanceof Error ? (error.stack ?? error.message) : String(error)), ); - const isNativeModuleError = - error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_DLOPEN_FAILED'; - const message = isDatabaseSchemaTooNewError(error) - ? formatDatabaseSchemaTooNewMessage(error) - : isNativeModuleError - ? `A native module failed to load. This usually means the app needs to be rebuilt for this version of Electron.\n\nFix: run "pnpm install" in the project directory, then restart.\n\nDetails: ${error.message}` - : `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`; - showFatalStartupError('Mesh-Client — Startup Error', message); + showFatalStartupError( + 'Mesh-Client — Startup Error', + `The application failed to start:\n\n${error instanceof Error ? error.message : String(error)}\n\nPlease report this issue.`, + ); app.quit(); - return; - } - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - try { - createWindow(); - } catch (error) { - console.error( - '[main] Window creation error:', - sanitizeLogMessage(error instanceof Error ? error.message : String(error)), - ); - } - } else { - mainWindow?.show(); // Restore hidden window on dock click - } }); -}); app.on('before-quit', (event) => { // Clean up any pending Bluetooth device selection to prevent callback leak diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 411a8964e..aa4d7bb50 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -1050,7 +1050,9 @@ export function ChatComposer({ if (isLinux) { setShowComposePicker((prev) => !prev); } else { - void window.electronAPI.showEmojiPanel(); + void window.electronAPI.showEmojiPanel().catch((e: unknown) => { + console.debug('[ChatComposer] showEmojiPanel failed ' + errLikeToLogString(e)); + }); } }} disabled={disabled || !isConnected} diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index a11fa8242..61be8907f 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -2852,7 +2852,14 @@ function ChatPanel({ } else { reactionPickerTarget.current = { id, channel: msg.channel }; reactionCapturePendingRef.current = true; - void window.electronAPI.showEmojiPanel(); + void window.electronAPI + .showEmojiPanel() + .catch((e: unknown) => { + console.debug( + '[ChatPanel] showEmojiPanel failed ' + + errLikeToLogString(e), + ); + }); } }} {...{ [PARENT_HOVER_ATTR]: '' }} diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 94b5bcfae..98779ef2d 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1242,7 +1242,9 @@ export default function ConnectionPanel({ setWebBluetoothDevice(null); } } else { - void window.electronAPI.stopNobleBleScanning(protocol); + void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { + console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); + }); } } if (showSerialPicker) { @@ -1319,7 +1321,9 @@ export default function ConnectionPanel({ // Don't call onConnect again - the original onConnect will continue from requestDevice() // and proceed to connect(), which triggers the pairing handler. } else { - void window.electronAPI.stopNobleBleScanning(protocol); + void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { + console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); + }); // Trigger the actual connection with the peripheral ID onConnect('ble', undefined, deviceId).catch((err: unknown) => { const errMsg = err instanceof Error ? err.message : String(err); diff --git a/src/renderer/components/LanguageSelector.tsx b/src/renderer/components/LanguageSelector.tsx index 8a258fb1c..7bac8c8b8 100644 --- a/src/renderer/components/LanguageSelector.tsx +++ b/src/renderer/components/LanguageSelector.tsx @@ -7,6 +7,7 @@ import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { mergeAppSetting } from '../lib/appSettingsStorage'; +import { errLikeToLogString } from '../lib/errLikeToLogString'; import i18n from '../lib/i18n'; import { ensureLocaleLoaded } from '../lib/localeResources'; import { SUPPORTED_LANGUAGES } from '../locales/languages'; @@ -40,13 +41,18 @@ export default function LanguageSelector() { // Reconcile DB locale with current i18n locale on mount useEffect(() => { - void window.electronAPI.appSettings.getAll().then(async (settings) => { - const dbLocale = settings.locale; - if (dbLocale && dbLocale !== i18n.language) { - const ok = await ensureLocaleLoaded(i18n, dbLocale); - if (ok) await i18n.changeLanguage(dbLocale); + void (async () => { + try { + const settings = await window.electronAPI.appSettings.getAll(); + const dbLocale = settings.locale; + if (dbLocale && dbLocale !== i18n.language) { + const ok = await ensureLocaleLoaded(i18n, dbLocale); + if (ok) await i18n.changeLanguage(dbLocale); + } + } catch (e: unknown) { + console.warn('[LanguageSelector] locale reconcile failed ' + errLikeToLogString(e)); } - }); + })(); }, []); // Close dropdown on outside click (button + portaled menu) @@ -88,7 +94,9 @@ export default function LanguageSelector() { } await i18n.changeLanguage(code); mergeAppSetting('locale', code, 'LanguageSelector'); - void window.electronAPI.appSettings.set('locale', code); + void window.electronAPI.appSettings.set('locale', code).catch((e: unknown) => { + console.warn('[LanguageSelector] persist locale failed ' + errLikeToLogString(e)); + }); closeMenu(); })(); }; diff --git a/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx b/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx index a2c553559..6a4e8eee3 100644 --- a/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx +++ b/src/renderer/components/MeshcoreRepeaterPasswordControls.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { forgetMeshcoreRepeaterSavedSecret, getMeshcoreRepeaterSavedSecretsSummary, @@ -43,12 +44,18 @@ export function MeshcoreRepeaterPasswordControls({