From 17a635da987d8db70b6ff347807fb59a42519249 Mon Sep 17 00:00:00 2001 From: Joe WB3IHY Date: Sun, 9 Aug 2026 19:47:24 -0400 Subject: [PATCH] fix(ble): don't throw from long-session health snapshot on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLongSessionHealthSnapshot() called the throwing getSession() unconditionally. On Linux, Noble is never initialized (Web Bluetooth is used in the renderer instead), so the sessions map stays empty for the app's whole lifetime. Once the hourly main-process health-log timer's 24h uptime gate opened, the first tick threw an uncaught "Unknown noble session: meshtastic" in the main process, surfaced to the user as the "Mesh-Client — Unexpected Error" dialog after about a day of uptime. Guard sessionDetail() the same way disconnectAll() and stopAllScanning() already guard for Linux, returning a benign not-initialized snapshot instead of throwing. Extract the shared isLinuxNotInitialized() check that guard duplicated across all three call sites into one private helper. --- src/main/noble-ble-manager.test.ts | 37 ++++++++++++++++++++++++++++++ src/main/noble-ble-manager.ts | 30 +++++++++++++++++++++--- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index 33ad020e1..542f6c35b 100644 --- a/src/main/noble-ble-manager.test.ts +++ b/src/main/noble-ble-manager.test.ts @@ -309,4 +309,41 @@ describe('NobleBleManager long-session maintenance (regression)', () => { expect(SOURCE).toContain('sessionAgeSec'); expect(SOURCE).toContain('getLongSessionHealthSnapshot'); }); + + /** + * On Linux, Noble is never initialized (Web Bluetooth is used in the renderer instead), + * so `sessions` stays empty for the app's whole lifetime. getLongSessionHealthSnapshot() + * used to call the throwing getSession() unconditionally, so once the hourly main-process + * health-log timer's 24h uptime gate opened, the very first tick threw an uncaught + * "Unknown noble session: meshtastic" in the main process — surfaced to the user as the + * "Mesh-Client — Unexpected Error" dialog on Linux after ~1 day of uptime. + */ + it('reports a benign not-initialized snapshot on Linux instead of throwing from getSession()', () => { + expect(SOURCE).toMatch( + /getLongSessionHealthSnapshot\(\)[\s\S]*?const linuxNotInitialized = this\.isLinuxNotInitialized\(\);/, + ); + // The guard must be checked inside sessionDetail() before the throwing getSession() call. + expect(SOURCE).toMatch( + /const sessionDetail = \(sessionId: 'meshtastic' \| 'meshcore'\) => \{\s*if \(linuxNotInitialized\) \{[\s\S]*?\};\s*\}\s*const session = this\.getSession\(sessionId\);/, + ); + expect(SOURCE).toContain( + 'getLongSessionHealthSnapshot: skipping session detail (not initialized on Linux)', + ); + }); + + /** + * Follow-up cleanup (code review, PR self-review): the `sessions.size === 0` Linux check was + * duplicated across getLongSessionHealthSnapshot(), stopAllScanning(), and disconnectAll(). + * Centralize it so all three call sites can't drift if the detection semantics ever change. + */ + it('centralizes the Linux not-initialized check in a shared helper used by all call sites', () => { + expect(SOURCE).toMatch( + /private isLinuxNotInitialized\(\): boolean \{\s*return this\.sessions\.size === 0;\s*\}/, + ); + expect(SOURCE).toContain('const linuxNotInitialized = this.isLinuxNotInitialized();'); + expect(SOURCE).toContain('if (this.isLinuxNotInitialized()) return;'); + expect(SOURCE).toContain('if (this.isLinuxNotInitialized()) {'); + // No remaining inline `sessions.size === 0` checks — everything routes through the helper. + expect(SOURCE).not.toMatch(/(?; }; } { + // On Linux, Noble is not initialized (Web Bluetooth is used in renderer instead), so + // getSession() below would throw. Report a benign "not initialized" snapshot instead. + const linuxNotInitialized = this.isLinuxNotInitialized(); + if (linuxNotInitialized) { + console.debug( + '[NobleBleManager] getLongSessionHealthSnapshot: skipping session detail (not initialized on Linux)', + ); + } const sessionDetail = (sessionId: 'meshtastic' | 'meshcore') => { + if (linuxNotInitialized) { + return { + connected: false, + peripheralId: null, + sessionAgeSec: null, + postWriteTimer: false, + notifyWatchdog: false, + gattInflight: false, + readPumpActive: false, + fromRadioPackets: 0, + }; + } const session = this.getSession(sessionId); const established = session.sessionEstablishedAtMs; return { @@ -783,7 +808,7 @@ export class NobleBleManager extends EventEmitter { /** Stop all scanning immediately — used for app quit and force-quit IPC. */ async stopAllScanning(): Promise { - if (this.sessions.size === 0) return; + if (this.isLinuxNotInitialized()) return; this.scanRequesters.clear(); await this.doStopScanning(); bleCoexistenceCoordinator.releaseScan('noble'); @@ -1715,8 +1740,7 @@ export class NobleBleManager extends EventEmitter { } async disconnectAll(): Promise { - // On Linux, Noble is not initialized (Web Bluetooth is used in renderer instead) - if (this.sessions.size === 0) { + if (this.isLinuxNotInitialized()) { console.debug('[NobleBleManager] disconnectAll: skipping (not initialized on Linux)'); return; }