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; }