From 76bfc3927aa82738891b5ae258409f980dfeedee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 12:02:11 +0000 Subject: [PATCH 1/2] refactor: share MeshCore and Meshtastic TCP bridge IPC Extract createTcpBridge so both protocols use one socket lifecycle. writeMissing preserves MeshCore reject vs Meshtastic no-socket. --- src/main/index.contract.test.ts | 45 ++-- src/main/index.ipc-security.test.ts | 291 ++++++++------------ src/main/index.ts | 399 +--------------------------- src/main/ipc/tcp-bridge.test.ts | 396 +++++++++++++++++++++++++++ src/main/ipc/tcp-bridge.ts | 290 ++++++++++++++++++++ 5 files changed, 821 insertions(+), 600 deletions(-) create mode 100644 src/main/ipc/tcp-bridge.test.ts create mode 100644 src/main/ipc/tcp-bridge.ts diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 0841320ca..858e507a6 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -5,15 +5,16 @@ import { describe, expect, it } from 'vitest'; const INDEX_SOURCE = readFileSync(join(__dirname, 'index.ts'), 'utf-8'); const PRELOAD_SOURCE = readFileSync(join(__dirname, '../preload/index.ts'), 'utf-8'); +const TCP_BRIDGE_SOURCE = readFileSync(join(__dirname, 'ipc/tcp-bridge.ts'), 'utf-8'); describe('IPC payload size limits (source contract)', () => { it('defines meshcore tcp-write, http:write, and noble-ble limits and uses them in handlers', () => { - expect(INDEX_SOURCE).toContain('const MESHCORE_TCP_WRITE_MAX_BYTES = 256 * 1024'); - expect(INDEX_SOURCE).toContain('MESHCORE_TCP_DATA_MAX_BYTES'); + expect(TCP_BRIDGE_SOURCE).toContain('export const TCP_BRIDGE_WRITE_MAX_BYTES = 256 * 1024'); + expect(TCP_BRIDGE_SOURCE).toContain('TCP_BRIDGE_DATA_MAX_BYTES'); expect(INDEX_SOURCE).toContain('const HTTP_WRITE_TO_RADIO_MAX_BYTES = 256 * 1024'); expect(INDEX_SOURCE).toContain('const NOBLE_BLE_TO_RADIO_MAX_BYTES = 512'); expect(INDEX_SOURCE).toMatch(/maxBytes: NOBLE_BLE_TO_RADIO_MAX_BYTES/); - expect(INDEX_SOURCE).toMatch(/bytes\.length > MESHCORE_TCP_WRITE_MAX_BYTES/); + expect(TCP_BRIDGE_SOURCE).toMatch(/bytes\.length > TCP_BRIDGE_WRITE_MAX_BYTES/); expect(INDEX_SOURCE).toMatch(/data\.length > HTTP_WRITE_TO_RADIO_MAX_BYTES/); expect(INDEX_SOURCE).toMatch(/http:write: byte values must be integers 0-255/); }); @@ -30,11 +31,13 @@ describe('Noble BLE disconnect handling (source contract)', () => { }); it('resolves meshtastic:tcp-write with no-socket instead of rejecting when the socket is gone', () => { - expect(INDEX_SOURCE).toMatch( - /meshtastic:tcp-write[\s\S]{0,800}console\.debug\('\[IPC\] meshtastic:tcp-write: no active socket'\)[\s\S]{0,80}return 'no-socket'/, + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'no-socket'"); + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'reject'"); + expect(TCP_BRIDGE_SOURCE).toMatch( + /writeMissing === 'no-socket'[\s\S]{0,200}console\.debug\(`\[IPC\] \$\{writeChannel\}: no active socket`\)[\s\S]{0,80}return 'no-socket'/, ); - expect(INDEX_SOURCE).toContain('meshtasticTcpWriteErrorIsNoSocket'); - expect(INDEX_SOURCE).toMatch(/sock\.destroyed \|\| sock\.writableEnded/); + expect(TCP_BRIDGE_SOURCE).toContain('meshtasticTcpWriteErrorIsNoSocket'); + expect(TCP_BRIDGE_SOURCE).toMatch(/sock\.destroyed \|\| sock\.writableEnded/); expect(PRELOAD_SOURCE).toMatch(/result === 'no-socket'/); expect(PRELOAD_SOURCE).toMatch(/throw new Error\('meshtastic:tcp-write: no active socket'\)/); }); @@ -417,27 +420,19 @@ describe('Host link quality IPC (source contract)', () => { }); it('wires live-session meters on both Meshtastic and MeshCore TCP bridges', () => { - expect(INDEX_SOURCE).toContain("resetLiveSessionMeter('meshtastic')"); - expect(INDEX_SOURCE).toContain("resetLiveSessionMeter('meshcore')"); - expect(INDEX_SOURCE).toContain("noteLiveSessionWrite('meshtastic')"); - expect(INDEX_SOURCE).toContain("noteLiveSessionWrite('meshcore')"); - expect(INDEX_SOURCE).toContain("noteLiveSessionData('meshtastic')"); - expect(INDEX_SOURCE).toContain("noteLiveSessionData('meshcore')"); - expect(INDEX_SOURCE).toContain("clearLiveSessionMeter('meshtastic')"); - expect(INDEX_SOURCE).toContain("clearLiveSessionMeter('meshcore')"); + expect(TCP_BRIDGE_SOURCE).toContain('resetLiveSessionMeter(protocol)'); + expect(TCP_BRIDGE_SOURCE).toContain('noteLiveSessionWrite(protocol)'); + expect(TCP_BRIDGE_SOURCE).toContain('noteLiveSessionData(protocol)'); + expect(TCP_BRIDGE_SOURCE).toContain('clearLiveSessionMeter(protocol)'); // Accounting must ignore superseded sockets (same active-ref guard as #792 disconnect IPC). - expect(INDEX_SOURCE).toMatch( - /if \(meshcoreTcpSocket === socket\) \{\s*noteLiveSessionData\('meshcore'\)/, - ); - expect(INDEX_SOURCE).toMatch( - /if \(meshtasticTcpSocket === socket\) \{\s*noteLiveSessionData\('meshtastic'\)/, + expect(TCP_BRIDGE_SOURCE).toMatch( + /if \(activeSocket === socket\) \{\s*noteLiveSessionData\(protocol\)/, ); - expect(INDEX_SOURCE).toMatch( - /if \(meshcoreTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshcore'\)/, - ); - expect(INDEX_SOURCE).toMatch( - /if \(meshtasticTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshtastic'\)/, + expect(TCP_BRIDGE_SOURCE).toMatch( + /if \(activeSocket === sock\) \{\s*noteLiveSessionWrite\(protocol\)/, ); + expect(INDEX_SOURCE).toContain('registerTcpBridgeIpcHandlers({'); + expect(INDEX_SOURCE).toContain('destroyRegisteredTcpBridgeSockets('); }); }); diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index ae7d50f23..621036447 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -11,6 +11,15 @@ const UPDATER_SOURCE = readFileSync(join(__dirname, 'updater.ts'), 'utf-8'); const SUPPORT_BUNDLE_SOURCE = readFileSync(join(__dirname, 'support-bundle.ts'), 'utf-8'); const TAK_IPC_SOURCE = readFileSync(join(__dirname, 'ipc/tak-handlers.ts'), 'utf-8'); const GPS_IPC_SOURCE = readFileSync(join(__dirname, 'ipc/gps-handlers.ts'), 'utf-8'); +const TCP_BRIDGE_SOURCE = readFileSync(join(__dirname, 'ipc/tcp-bridge.ts'), 'utf-8'); + +function ipcHandlerBody(channel: string, span = 400): string { + for (const src of [INDEX_SOURCE, TCP_BRIDGE_SOURCE, TAK_IPC_SOURCE, GPS_IPC_SOURCE]) { + const idx = src.indexOf(`ipcMain.handle('${channel}'`); + if (idx >= 0) return src.slice(idx, idx + span); + } + return ''; +} // ─── http:preflight / http:connect hostname validation ────────────── @@ -90,102 +99,72 @@ describe('validateHttpHost (source contract)', () => { }); }); -// ─── meshtastic:tcp-write byte element validation ─────────────────── +// ─── shared TCP bridge write / connect contracts ──────────────────── -describe('meshtastic:tcp-write byte validation (source contract)', () => { +describe('tcp-bridge write and connect (source contract)', () => { it('validates individual byte elements in addition to array length', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-write'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 600); + const writeIdx = TCP_BRIDGE_SOURCE.indexOf('const write = '); + expect(writeIdx).toBeGreaterThan(-1); + const handlerBody = TCP_BRIDGE_SOURCE.slice(writeIdx, writeIdx + 800); expect(handlerBody).toContain('Number.isInteger(b)'); expect(handlerBody).toContain('b >= 0'); expect(handlerBody).toContain('b <= 255'); }); - it('defines a 256 KB cap on meshtastic tcp-write payloads', () => { - expect(INDEX_SOURCE).toContain('const MESHTASTIC_TCP_WRITE_MAX_BYTES = 256 * 1024'); + it('defines a 256 KB cap on tcp-write payloads', () => { + expect(TCP_BRIDGE_SOURCE).toContain('export const TCP_BRIDGE_WRITE_MAX_BYTES = 256 * 1024'); }); it('rejects connect when port is out of range', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + const connectIdx = TCP_BRIDGE_SOURCE.indexOf('const connect = '); + expect(connectIdx).toBeGreaterThan(-1); + const handlerBody = TCP_BRIDGE_SOURCE.slice(connectIdx, connectIdx + 500); expect(handlerBody).toContain('p < 1'); expect(handlerBody).toContain('p > 65535'); }); - it('destroys prior socket before opening a new meshtastic tcp connection', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); + it('destroys prior socket before opening a new tcp connection', () => { + const connectIdx = TCP_BRIDGE_SOURCE.indexOf('const connect = '); + expect(connectIdx).toBeGreaterThan(-1); + const handlerBody = TCP_BRIDGE_SOURCE.slice(connectIdx, connectIdx + 1400); // Null the active ref before destroy so the superseded close does not emit - // meshtastic:tcp-disconnected against a healthy replacement (#792). + // tcp-disconnected against a healthy replacement (#792). expect(handlerBody).toMatch( - /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/, + /const prev = activeSocket;\s*activeSocket = null;\s*clearLiveSessionMeter\(protocol\);\s*prev\.destroy\(\)/, ); }); - it('emits meshtastic:tcp-disconnected only for the active socket (PR #792)', () => { - // connect/disconnect null the ref before destroy(); a superseded close must not broadcast - // or the renderer TCP loss-watch will tear down a healthy replacement session. - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx); - expect(closeIdx).toBeGreaterThan(handlerIdx); - const closeBody = INDEX_SOURCE.slice(closeIdx, closeIdx + 900); - expect(closeBody).toContain('if (meshtasticTcpSocket === socket)'); - expect(closeBody).toContain("mainWindow?.webContents.send('meshtastic:tcp-disconnected')"); - // Emit must be inside the active-socket guard (not before it). - const guardIdx = closeBody.indexOf('if (meshtasticTcpSocket === socket)'); - const emitIdx = closeBody.indexOf( - "mainWindow?.webContents.send('meshtastic:tcp-disconnected')", - ); + it('emits tcp-disconnected only for the active socket (PR #792)', () => { + const closeIdx = TCP_BRIDGE_SOURCE.indexOf("socket.on('close'"); + expect(closeIdx).toBeGreaterThan(-1); + const closeBody = TCP_BRIDGE_SOURCE.slice(closeIdx, closeIdx + 1600); + expect(closeBody).toContain('if (activeSocket === socket)'); + expect(closeBody).toContain('getMainWindow()?.webContents.send(disconnectedChannel)'); + expect(closeBody).toContain('readableEnded'); + expect(closeBody).toContain('writableEnded'); + expect(closeBody).toContain('remoteAddress'); + const guardIdx = closeBody.indexOf('if (activeSocket === socket)'); + const emitIdx = closeBody.indexOf('getMainWindow()?.webContents.send(disconnectedChannel)'); expect(guardIdx).toBeGreaterThan(-1); expect(emitIdx).toBeGreaterThan(guardIdx); }); - it('nulls meshtasticTcpSocket before destroy on disconnect (PR #792)', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-disconnect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500); + it('nulls activeSocket before destroy on disconnect (PR #792)', () => { + const disconnectIdx = TCP_BRIDGE_SOURCE.indexOf('const disconnect = '); + expect(disconnectIdx).toBeGreaterThan(-1); + const handlerBody = TCP_BRIDGE_SOURCE.slice(disconnectIdx, disconnectIdx + 500); expect(handlerBody).toMatch( - /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/, + /const prev = activeSocket;\s*activeSocket = null;\s*clearLiveSessionMeter\(protocol\);\s*prev\.destroy\(\)/, ); }); - it('does not null meshtasticTcpSocket in the error handler (error-before-close race)', () => { - // Node emits 'error' then 'close' on ECONNRESET. If error nulls the ref first, close's - // active-socket guard fails and meshtastic:tcp-disconnected is swallowed. - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const errorIdx = INDEX_SOURCE.indexOf("socket.on('error'", handlerIdx); - expect(errorIdx).toBeGreaterThan(handlerIdx); - const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx); - expect(closeIdx).toBeGreaterThan(handlerIdx); + it('does not null activeSocket in the error handler (error-before-close race)', () => { + const errorIdx = TCP_BRIDGE_SOURCE.indexOf("socket.on('error'"); + const closeIdx = TCP_BRIDGE_SOURCE.indexOf("socket.on('close'"); expect(errorIdx).toBeGreaterThan(closeIdx); - const errorBody = INDEX_SOURCE.slice(errorIdx, errorIdx + 500); - expect(errorBody).not.toMatch(/meshtasticTcpSocket\s*=\s*null/); - expect(errorBody).toContain('Do not null meshtasticTcpSocket'); - }); -}); - -// ─── meshcore:tcp-write byte element validation ────────────────────── - -describe('meshcore:tcp-write byte validation (source contract)', () => { - it('validates individual byte elements in addition to array length', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-write'"); - expect(handlerIdx).toBeGreaterThan(-1); - // Read enough of the handler to see the element validation - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 600); - - // Must check each byte is a valid 0-255 integer - expect(handlerBody).toContain('Number.isInteger(b)'); - expect(handlerBody).toContain('b >= 0'); - expect(handlerBody).toContain('b <= 255'); - }); - - it('defines a 256 KB cap on tcp-write payloads', () => { - expect(INDEX_SOURCE).toContain('const MESHCORE_TCP_WRITE_MAX_BYTES = 256 * 1024'); + const errorBody = TCP_BRIDGE_SOURCE.slice(errorIdx, errorIdx + 500); + expect(errorBody).not.toMatch(/activeSocket\s*=\s*null/); + expect(errorBody).toContain('Do not null the active socket'); }); }); @@ -349,89 +328,40 @@ describe('session permission whitelist (source contract)', () => { }); }); -// ─── meshcore:tcp-connect hostname validation ──────────────────────── +// ─── tcp-bridge hostname validation ───────────────────────────────── -describe('meshcore:tcp-connect hostname validation (source contract)', () => { - it('calls validateHttpHost in the meshcore:tcp-connect handler', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 600); - expect(handlerBody).toContain('validateHttpHost('); +describe('tcp-bridge hostname validation (source contract)', () => { + it('calls validateHost in the shared connect handler', () => { + const connectIdx = TCP_BRIDGE_SOURCE.indexOf('const connect = '); + expect(connectIdx).toBeGreaterThan(-1); + const handlerBody = TCP_BRIDGE_SOURCE.slice(connectIdx, connectIdx + 600); + expect(handlerBody).toContain('validateHost('); }); - it('does not use a bare length-only host check in meshcore:tcp-connect', () => { + it('does not use a bare length-only host check in tcp-connect', () => { // The old pattern was: typeof host !== 'string' || host.length === 0 || host.length > MAX_TCP_HOST_LENGTH // It should now delegate entirely to validateHttpHost which applies isValidHttpHostname expect(INDEX_SOURCE).not.toContain('MAX_TCP_HOST_LENGTH'); + expect(TCP_BRIDGE_SOURCE).not.toContain('MAX_TCP_HOST_LENGTH'); }); it('normalizes bracketed IPv6 before net.Socket.connect', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); - expect(handlerBody).toContain('formatHostForSocket('); - }); - - it('emits meshcore:tcp-disconnected only for the active socket (PR #792)', () => { - // Same contract as meshtastic:tcp-connect — superseded closes from connect-replace / - // disconnect must not look like a live link drop to the renderer reconnect path. - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx); - expect(closeIdx).toBeGreaterThan(handlerIdx); - const closeBody = INDEX_SOURCE.slice(closeIdx, closeIdx + 1600); - expect(closeBody).toContain('if (meshcoreTcpSocket === socket)'); - expect(closeBody).toContain("mainWindow?.webContents.send('meshcore:tcp-disconnected')"); - expect(closeBody).toContain('readableEnded'); - expect(closeBody).toContain('writableEnded'); - expect(closeBody).toContain('remoteAddress'); - const guardIdx = closeBody.indexOf('if (meshcoreTcpSocket === socket)'); - const emitIdx = closeBody.indexOf("mainWindow?.webContents.send('meshcore:tcp-disconnected')"); - expect(guardIdx).toBeGreaterThan(-1); - expect(emitIdx).toBeGreaterThan(guardIdx); - }); - - it('nulls meshcoreTcpSocket before destroy on connect-replace and disconnect (PR #792)', () => { - const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); + const connectIdx = TCP_BRIDGE_SOURCE.indexOf('const connect = '); expect(connectIdx).toBeGreaterThan(-1); - const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1400); - expect(connectBody).toMatch( - /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*clearLiveSessionMeter\('meshcore'\);\s*prev\.destroy\(\)/, - ); - - const disconnectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-disconnect'"); - expect(disconnectIdx).toBeGreaterThan(-1); - const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 500); - expect(disconnectBody).toMatch( - /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*clearLiveSessionMeter\('meshcore'\);\s*prev\.destroy\(\)/, - ); + const handlerBody = TCP_BRIDGE_SOURCE.slice(connectIdx, connectIdx + 1400); + expect(handlerBody).toContain('formatHostForSocket('); }); - it('enables TCP_NODELAY and keepalive on meshcore:tcp-connect sockets', () => { - expect(INDEX_SOURCE).toContain('MESHCORE_TCP_KEEPALIVE_INITIAL_DELAY_MS'); - const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); + it('enables TCP_NODELAY and keepalive on connect sockets', () => { + expect(TCP_BRIDGE_SOURCE).toContain('TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS'); + const connectIdx = TCP_BRIDGE_SOURCE.indexOf('const connect = '); expect(connectIdx).toBeGreaterThan(-1); - const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1600); + const connectBody = TCP_BRIDGE_SOURCE.slice(connectIdx, connectIdx + 1600); expect(connectBody).toContain('socket.setNoDelay(true)'); expect(connectBody).toContain( - 'socket.setKeepAlive(true, MESHCORE_TCP_KEEPALIVE_INITIAL_DELAY_MS)', + 'socket.setKeepAlive(true, TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS)', ); }); - - it('does not null meshcoreTcpSocket in the error handler (error-before-close race)', () => { - // Node emits 'error' then 'close' on ECONNRESET. If error nulls the ref first, close's - // active-socket guard fails and meshcore:tcp-disconnected is swallowed (n7eal). - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const errorIdx = INDEX_SOURCE.indexOf("socket.on('error'", handlerIdx); - expect(errorIdx).toBeGreaterThan(handlerIdx); - const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx); - expect(closeIdx).toBeGreaterThan(handlerIdx); - expect(errorIdx).toBeGreaterThan(closeIdx); - const errorBody = INDEX_SOURCE.slice(errorIdx, errorIdx + 500); - expect(errorBody).not.toMatch(/meshcoreTcpSocket\s*=\s*null/); - expect(errorBody).toContain('Do not null meshcoreTcpSocket'); - }); }); describe('hostLink:getSessionMeter validation (source contract)', () => { @@ -447,43 +377,32 @@ describe('hostLink:getSessionMeter validation (source contract)', () => { }); }); -// ─── meshtastic:tcp-connect hostname validation ────────────────────── - -describe('meshtastic:tcp-connect hostname validation (source contract)', () => { - it('calls validateHttpHost in the meshtastic:tcp-connect handler', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 600); - expect(handlerBody).toContain('validateHttpHost('); - }); - - it('normalizes bracketed IPv6 before net.Socket.connect', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); - expect(handlerBody).toContain('formatHostForSocket('); - }); - - it('uses an independent socket ref from meshcore:tcp-connect', () => { - expect(INDEX_SOURCE).toContain('let meshtasticTcpSocket: net.Socket | null = null;'); - }); - - it('destroys the socket on oversized meshtastic:tcp-data chunks without emitting', () => { - const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); - expect(handlerIdx).toBeGreaterThan(-1); - const dataIdx = INDEX_SOURCE.indexOf("socket.on('data'", handlerIdx); - expect(dataIdx).toBeGreaterThan(handlerIdx); - const dataBody = INDEX_SOURCE.slice(dataIdx, dataIdx + 900); - expect(dataBody).toContain('MESHTASTIC_TCP_DATA_MAX_BYTES'); - expect(dataBody).toContain('meshtastic:tcp-data oversized chunk'); +describe('tcp-bridge protocol wiring (source contract)', () => { + it('creates independent MeshCore and Meshtastic bridges with writeMissing flags', () => { + expect(INDEX_SOURCE).toContain('registerTcpBridgeIpcHandlers({'); + expect(INDEX_SOURCE).toContain('validateHost: validateHttpHost'); + expect(TCP_BRIDGE_SOURCE).toContain("protocol: 'meshcore'"); + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'reject'"); + expect(TCP_BRIDGE_SOURCE).toContain("protocol: 'meshtastic'"); + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'no-socket'"); + expect(TCP_BRIDGE_SOURCE).toContain('let activeSocket: net.Socket | null = null;'); + expect(TCP_BRIDGE_SOURCE).toMatch(/ipcMain\.handle\('meshcore:tcp-connect'/); + expect(TCP_BRIDGE_SOURCE).toMatch(/ipcMain\.handle\('meshtastic:tcp-connect'/); + }); + + it('destroys the socket on oversized tcp-data chunks without emitting', () => { + const dataIdx = TCP_BRIDGE_SOURCE.indexOf("socket.on('data'"); + expect(dataIdx).toBeGreaterThan(-1); + const dataBody = TCP_BRIDGE_SOURCE.slice(dataIdx, dataIdx + 900); + expect(dataBody).toContain('TCP_BRIDGE_DATA_MAX_BYTES'); + expect(dataBody).toContain('oversized chunk'); expect(dataBody).toContain('socket.destroy()'); - const oversizeIdx = dataBody.indexOf('chunk.length > MESHTASTIC_TCP_DATA_MAX_BYTES'); + const oversizeIdx = dataBody.indexOf('chunk.length > TCP_BRIDGE_DATA_MAX_BYTES'); const destroyIdx = dataBody.indexOf('socket.destroy()'); - const emitIdx = dataBody.indexOf("mainWindow?.webContents.send('meshtastic:tcp-data'"); + const emitIdx = dataBody.indexOf('getMainWindow()?.webContents.send(dataChannel'); expect(oversizeIdx).toBeGreaterThan(-1); expect(destroyIdx).toBeGreaterThan(oversizeIdx); expect(emitIdx).toBeGreaterThan(destroyIdx); - // Oversized branch returns before the emit (emit is only on the success path after return). const returnAfterDestroy = dataBody.slice(destroyIdx, emitIdx); expect(returnAfterDestroy).toContain('return;'); }); @@ -576,12 +495,6 @@ describe('privileged IPC sender validation (source contract)', () => { 'storage:decrypt', 'http:write', 'http:disconnect', - 'meshcore:tcp-connect', - 'meshcore:tcp-write', - 'meshcore:tcp-disconnect', - 'meshtastic:tcp-connect', - 'meshtastic:tcp-write', - 'meshtastic:tcp-disconnect', 'hostLink:getSessionMeter', 'noble-ble-connect', 'noble-ble-disconnect', @@ -631,15 +544,28 @@ describe('privileged IPC sender validation (source contract)', () => { ] as const; it.each(privilegedChannels)('%s calls assertIpcSender or validateIpcSender', (channel) => { - const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.handle('${channel}'`); - expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + const handlerBody = ipcHandlerBody(channel); + expect(handlerBody.length).toBeGreaterThan(0); expect( handlerBody.includes('assertIpcSender(event') || handlerBody.includes('validateIpcSender(event)'), ).toBe(true); }); + it.each([ + 'meshcore:tcp-connect', + 'meshcore:tcp-write', + 'meshcore:tcp-disconnect', + 'meshtastic:tcp-connect', + 'meshtastic:tcp-write', + 'meshtastic:tcp-disconnect', + ] as const)('%s is registered from the shared TCP bridge with assertIpcSender', (channel) => { + expect(TCP_BRIDGE_SOURCE).toContain(`ipcMain.handle('${channel}'`); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, connectChannel)'); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, writeChannel)'); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, disconnectChannel)'); + }); + it.each(['device-connected', 'device-disconnected'] as const)( '%s validates the IPC sender', (channel) => { @@ -688,18 +614,9 @@ describe('privileged IPC sender validation (source contract)', () => { ); }); - it('meshcore tcp-connect uses connect timeout', () => { - expect(INDEX_SOURCE).toContain('MESHCORE_TCP_CONNECT_TIMEOUT_MS'); - expect(INDEX_SOURCE).toMatch( - /meshcore:tcp-connect[\s\S]{0,1800}meshcore:tcp-connect: connection timeout/, - ); - }); - - it('meshtastic tcp-connect uses connect timeout', () => { - expect(INDEX_SOURCE).toContain('MESHTASTIC_TCP_CONNECT_TIMEOUT_MS'); - expect(INDEX_SOURCE).toMatch( - /meshtastic:tcp-connect[\s\S]{0,1800}meshtastic:tcp-connect: connection timeout/, - ); + it('tcp-bridge connect uses a shared connect timeout', () => { + expect(TCP_BRIDGE_SOURCE).toContain('TCP_BRIDGE_CONNECT_TIMEOUT_MS'); + expect(TCP_BRIDGE_SOURCE).toMatch(/\$\{connectChannel\}: connection timeout/); }); it('validateMqttSettings rejects invalid broker hostnames', () => { diff --git a/src/main/index.ts b/src/main/index.ts index b95cb9c2c..d9ecd838b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,7 +21,6 @@ import { Tray, } from 'electron'; import fs from 'fs'; -import net from 'net'; import path from 'path'; import zlib from 'zlib'; @@ -35,7 +34,7 @@ import { } from '../shared/appSettingsKeyPrefixes'; import { APP_ABOUT_TAGLINE } from '../shared/appTagline'; import { clampQueryLimit } from '../shared/clampQueryLimit'; -import { formatHostForSocket, parseConnectHostPort } from '../shared/connectHost'; +import { parseConnectHostPort } from '../shared/connectHost'; import { NODES_LAST_HEARD_SEC_SQL, normalizeLastHeardToUnixSec } from '../shared/lastHeardUnits'; import { findLxmUrlInArgv, isForwardableMeshClientOpenUrl } from '../shared/meshClientDeepLink'; import { @@ -121,6 +120,7 @@ import { registerReticulumIpcHandlers, wireReticulumSidecarBridge } from './ipc/ import { registerReticulumIdentityIpcHandlers } from './ipc/reticulum-identity-handlers'; import { registerRrcDbIpcHandlers } from './ipc/rrc-db-handlers'; import { registerTakIpcHandlers } from './ipc/tak-handlers'; +import { destroyRegisteredTcpBridgeSockets, registerTcpBridgeIpcHandlers } from './ipc/tcp-bridge'; import { createIpcRateLimiter } from './ipcRateLimit'; import { registerLinuxWebBluetoothCancelIpcHandlers } from './linuxWebBluetoothCancelIpc'; import { @@ -128,13 +128,7 @@ import { linuxWebBluetoothDeviceSelection, } from './linuxWebBluetoothDeviceSelection'; import { listMeshcoreDmPeersFromDb, listMeshtasticDmPeersFromDb } from './listDmPeers'; -import { - clearLiveSessionMeter, - noteLiveSessionData, - noteLiveSessionWrite, - resetLiveSessionMeter, - snapshotLiveSessionMeter, -} from './live-session-meter'; +import { snapshotLiveSessionMeter } from './live-session-meter'; import { clearLogFile, exportLogTo, @@ -156,7 +150,6 @@ import { } from './longSessionNudge'; import { MeshcoreMqttAdapter } from './meshcore-mqtt-adapter'; import { decodePathPayload, isPathPacket } from './meshcore-path-decoder'; -import { meshtasticTcpWriteErrorIsNoSocket } from './meshtasticTcpWriteResult'; import { ensureMicrophoneAccess, isAllowedMicrophonePrivacySettingsUrl } from './microphoneAccess'; import { resolveMqttBrokerClientId } from './mqtt-broker-client-id'; import { type CachedNode, MQTTManager, parsePsk } from './mqtt-manager'; @@ -336,10 +329,6 @@ async function ensureTakServerManager(): Promise { return takServerManagerLoadPromise; } -/** Max bytes per MeshCore TCP IPC write (DoS guard). */ -const MESHCORE_TCP_WRITE_MAX_BYTES = 256 * 1024; -/** Cap per-chunk IPC fan-out from OpenHop/companion TCP reads (align with write max). */ -const MESHCORE_TCP_DATA_MAX_BYTES = MESHCORE_TCP_WRITE_MAX_BYTES; /** Min node ID for MeshCore chat stub nodes (derived from meshcoreUtils). */ const MESHCORE_CHAT_STUB_ID_MIN = 0xa0000000 >>> 0; /** Max node ID for MeshCore chat stub nodes (derived from meshcoreUtils). */ @@ -448,28 +437,7 @@ async function quitMainProcess(opts: { relaunch?: boolean } = {}): Promise await shutdownAppResources(); - if (meshcoreTcpSocket) { - try { - meshcoreTcpSocket.destroy(); - } catch (err) { - console.debug( - '[main] quitMainProcess TCP socket destroy (ignored):', - err instanceof Error ? err.message : err, - ); // log-injection-ok internal Node.js socket error during cleanup - } - meshcoreTcpSocket = null; - } - if (meshtasticTcpSocket) { - try { - meshtasticTcpSocket.destroy(); - } catch (err) { - console.debug( - '[main] quitMainProcess TCP socket destroy (ignored):', - err instanceof Error ? err.message : err, - ); // log-injection-ok internal Node.js socket error during cleanup - } - meshtasticTcpSocket = null; - } + destroyRegisteredTcpBridgeSockets('quitMainProcess TCP socket destroy (ignored)'); stopPowerSaveBlocker(); nobleBleManager.releaseNobleProcessHandles(); @@ -6325,326 +6293,12 @@ ipcMain.handle('db:deleteAllMeshcorePathHistory', (event) => { } }); -// ─── MeshCore TCP bridge ─────────────────────────────────────────── -let meshcoreTcpSocket: net.Socket | null = null; - -ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { - assertIpcSender(event, 'meshcore:tcp-connect'); - return new Promise((resolve, reject) => { - let settled = false; - const p = port; - if (!Number.isInteger(p) || p < 1 || p > 65535) { - reject(new Error('Invalid port')); - return; - } - try { - validateHttpHost(host); - } catch (err) { - // catch-no-log-ok validation error forwarded to promise reject - reject(err instanceof Error ? err : new Error(String(err))); - return; - } - if (meshcoreTcpSocket) { - // Null before destroy so the superseded socket's 'close' does not emit - // meshcore:tcp-disconnected (renderer reconnect is driven by that event — #792). - const prev = meshcoreTcpSocket; - meshcoreTcpSocket = null; - clearLiveSessionMeter('meshcore'); - prev.destroy(); - } - const socketHost = formatHostForSocket(host); - const socket = new net.Socket(); - // MeshCore Open / official companion TCP clients use TCP_NODELAY; Node defaults can - // Nagle-batch small companion RPCs and OpenHop peers often FIN mid-init. - socket.setNoDelay(true); - socket.setKeepAlive(true, MESHCORE_TCP_KEEPALIVE_INITIAL_DELAY_MS); - meshcoreTcpSocket = socket; - const connectTimeout = setTimeout(() => { - if (settled) return; - settled = true; - if (meshcoreTcpSocket === socket) { - meshcoreTcpSocket = null; - clearLiveSessionMeter('meshcore'); - } - socket.destroy(); - reject(new Error('meshcore:tcp-connect: connection timeout')); - }, MESHCORE_TCP_CONNECT_TIMEOUT_MS); - socket.connect(p, socketHost, () => { - clearTimeout(connectTimeout); - console.debug('[IPC] meshcore:tcp-connect connected to', sanitizeLogMessage(socketHost), p); - logDeviceConnection( - `transport=tcp stack=meshcore host=${sanitizeLogMessage(socketHost)} port=${p}`, - ); - resetLiveSessionMeter('meshcore'); - if (!settled) { - settled = true; - resolve(); - } - }); - socket.on('data', (data) => { - const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data); - if (chunk.length > MESHCORE_TCP_DATA_MAX_BYTES) { - console.warn( - `[IPC] meshcore:tcp-data oversized chunk (${chunk.length} > ${MESHCORE_TCP_DATA_MAX_BYTES}); dropping socket`, - ); - try { - socket.destroy(); - } catch (e) { - console.debug( - '[IPC] meshcore:tcp-data destroy after oversize ' + - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } - return; - } - // Superseded sockets must not update the live session meter (#792 connect-replace). - if (meshcoreTcpSocket === socket) { - noteLiveSessionData('meshcore'); - } - mainWindow?.webContents.send('meshcore:tcp-data', new Uint8Array(chunk)); - }); - socket.on('close', (hadError) => { - clearTimeout(connectTimeout); - // readableEnded=true after peer FIN; local destroy-before-null tear downs do not hit this - // branch as active (ref cleared first). Log fields help triage n7eal post-contacts hangs. - const remote = socket.remoteAddress - ? `${socket.remoteAddress}:${socket.remotePort ?? '?'}` - : 'unknown'; - console.debug( - '[IPC] meshcore:tcp socket closed', - hadError ? '(hadError)' : '(clean)', - `remote=${sanitizeLogMessage(remote)}`, - `readableEnded=${socket.readableEnded}`, - `writableEnded=${socket.writableEnded}`, - ); - // Only notify when this socket is still the active bridge. connect/disconnect clear the - // ref before destroy(), so superseded closes must not look like a live link drop - // (renderer reconnect is driven by this event — see #792). - if (meshcoreTcpSocket === socket) { - meshcoreTcpSocket = null; - clearLiveSessionMeter('meshcore'); - mainWindow?.webContents.send('meshcore:tcp-disconnected'); - } - }); - socket.on('error', (err) => { - clearTimeout(connectTimeout); - console.error('[IPC] meshcore:tcp-connect error:', sanitizeLogMessage(err.message)); - if (!settled) { - settled = true; - reject(err); - } - // Do not null meshcoreTcpSocket here. Node fires 'error' before 'close' on ECONNRESET - // etc.; nulling early makes close's active-socket guard fail and swallows - // meshcore:tcp-disconnected (renderer never reconnects). close owns that transition. - }); - }); -}); - -ipcMain.handle('meshcore:tcp-write', (event, bytes: number[]) => { - assertIpcSender(event, 'meshcore:tcp-write'); - if (!Array.isArray(bytes) || bytes.length > MESHCORE_TCP_WRITE_MAX_BYTES) { - return Promise.reject( - new Error( - `meshcore:tcp-write: invalid or oversized payload (max ${MESHCORE_TCP_WRITE_MAX_BYTES} bytes)`, - ), - ); - } - // Validate each element is a valid byte value so Uint8Array coercion is not silently lossy. - if (!bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) { - return Promise.reject(new Error('meshcore:tcp-write: byte values must be integers 0-255')); - } - if (!meshcoreTcpSocket) { - const msg = 'meshcore:tcp-write: no active socket'; - console.warn(`[IPC] ${msg}`); - return Promise.reject(new Error(msg)); - } - const sock = meshcoreTcpSocket; - return new Promise((resolve, reject) => { - sock.write(new Uint8Array(bytes), (err) => { - if (err) { - console.error('[IPC] meshcore:tcp-write error:', sanitizeLogMessage(err.message)); - reject(err); - } else { - // Ignore write completions from a superseded socket. - if (meshcoreTcpSocket === sock) { - noteLiveSessionWrite('meshcore'); - } - resolve(); - } - }); - }); -}); - -ipcMain.handle('meshcore:tcp-disconnect', (event) => { - assertIpcSender(event, 'meshcore:tcp-disconnect'); - if (meshcoreTcpSocket) { - console.debug('[IPC] meshcore:tcp-disconnect'); - // Null before destroy so this teardown close is not reported as a live link drop. - const prev = meshcoreTcpSocket; - meshcoreTcpSocket = null; - clearLiveSessionMeter('meshcore'); - prev.destroy(); - } -}); - -// ─── Meshtastic TCP bridge ────────────────────────────────────────── -// Independent from meshcoreTcpSocket: Meshtastic and MeshCore may be -// connected simultaneously, each over its own transport. -let meshtasticTcpSocket: net.Socket | null = null; - -ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => { - assertIpcSender(event, 'meshtastic:tcp-connect'); - return new Promise((resolve, reject) => { - let settled = false; - const p = port; - if (!Number.isInteger(p) || p < 1 || p > 65535) { - reject(new Error('Invalid port')); - return; - } - try { - validateHttpHost(host); - } catch (err) { - // catch-no-log-ok validation error forwarded to promise reject - reject(err instanceof Error ? err : new Error(String(err))); - return; - } - if (meshtasticTcpSocket) { - // Null before destroy so the superseded socket's 'close' does not emit - // meshtastic:tcp-disconnected (renderer reconnect is driven by that event — #792). - const prev = meshtasticTcpSocket; - meshtasticTcpSocket = null; - clearLiveSessionMeter('meshtastic'); - prev.destroy(); - } - const socketHost = formatHostForSocket(host); - const socket = new net.Socket(); - socket.setNoDelay(true); - socket.setKeepAlive(true, MESHTASTIC_TCP_KEEPALIVE_INITIAL_DELAY_MS); - meshtasticTcpSocket = socket; - const connectTimeout = setTimeout(() => { - if (settled) return; - settled = true; - if (meshtasticTcpSocket === socket) { - meshtasticTcpSocket = null; - clearLiveSessionMeter('meshtastic'); - } - socket.destroy(); - reject(new Error('meshtastic:tcp-connect: connection timeout')); - }, MESHTASTIC_TCP_CONNECT_TIMEOUT_MS); - socket.connect(p, socketHost, () => { - clearTimeout(connectTimeout); - console.debug('[IPC] meshtastic:tcp-connect connected to', sanitizeLogMessage(socketHost), p); - logDeviceConnection( - `transport=tcp stack=meshtastic host=${sanitizeLogMessage(socketHost)} port=${p}`, - ); - resetLiveSessionMeter('meshtastic'); - if (!settled) { - settled = true; - resolve(); - } - }); - socket.on('data', (data) => { - const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data); - if (chunk.length > MESHTASTIC_TCP_DATA_MAX_BYTES) { - console.warn( - `[IPC] meshtastic:tcp-data oversized chunk (${chunk.length} > ${MESHTASTIC_TCP_DATA_MAX_BYTES}); dropping socket`, - ); - try { - socket.destroy(); - } catch (e) { - console.debug( - '[IPC] meshtastic:tcp-data destroy after oversize ' + - sanitizeLogMessage(e instanceof Error ? e.message : String(e)), - ); - } - return; - } - // Superseded sockets must not update the live session meter (#792 connect-replace). - if (meshtasticTcpSocket === socket) { - noteLiveSessionData('meshtastic'); - } - mainWindow?.webContents.send('meshtastic:tcp-data', new Uint8Array(chunk)); - }); - socket.on('close', (hadError) => { - clearTimeout(connectTimeout); - console.debug('[IPC] meshtastic:tcp socket closed', hadError ? '(hadError)' : '(clean)'); - // Only notify when this socket is still the active bridge. connect/disconnect clear the - // ref before destroy(), so superseded closes must not look like a live link drop - // (renderer reconnect is driven by this event — see #792). - if (meshtasticTcpSocket === socket) { - meshtasticTcpSocket = null; - clearLiveSessionMeter('meshtastic'); - mainWindow?.webContents.send('meshtastic:tcp-disconnected'); - } - }); - socket.on('error', (err) => { - clearTimeout(connectTimeout); - console.error('[IPC] meshtastic:tcp-connect error:', sanitizeLogMessage(err.message)); - if (!settled) { - settled = true; - reject(err); - } - // Do not null meshtasticTcpSocket here. Node fires 'error' before 'close' on ECONNRESET - // etc.; nulling early makes close's active-socket guard fail and swallows - // meshtastic:tcp-disconnected (renderer never reconnects). close owns that transition. - }); - }); -}); - -ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => { - assertIpcSender(event, 'meshtastic:tcp-write'); - if (!Array.isArray(bytes) || bytes.length > MESHTASTIC_TCP_WRITE_MAX_BYTES) { - return Promise.reject( - new Error( - `meshtastic:tcp-write: invalid or oversized payload (max ${MESHTASTIC_TCP_WRITE_MAX_BYTES} bytes)`, - ), - ); - } - // Validate each element is a valid byte value so Uint8Array coercion is not silently lossy. - if (!bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) { - return Promise.reject(new Error('meshtastic:tcp-write: byte values must be integers 0-255')); - } - if (!meshtasticTcpSocket) { - // Expected reconnect race — resolve so Electron does not log handler [error]. - console.debug('[IPC] meshtastic:tcp-write: no active socket'); - return 'no-socket'; - } - const sock = meshtasticTcpSocket; - if (sock.destroyed || sock.writableEnded) { - console.debug('[IPC] meshtastic:tcp-write: no active socket'); - return 'no-socket'; - } - return new Promise<'no-socket' | undefined>((resolve, reject) => { - sock.write(new Uint8Array(bytes), (err) => { - if (err) { - if (meshtasticTcpWriteErrorIsNoSocket(sock, err)) { - console.debug('[IPC] meshtastic:tcp-write: no active socket'); - resolve('no-socket'); - return; - } - console.error('[IPC] meshtastic:tcp-write error:', sanitizeLogMessage(err.message)); - reject(err); - } else { - // Ignore write completions from a superseded socket. - if (meshtasticTcpSocket === sock) { - noteLiveSessionWrite('meshtastic'); - } - resolve(undefined); - } - }); - }); -}); - -ipcMain.handle('meshtastic:tcp-disconnect', (event) => { - assertIpcSender(event, 'meshtastic:tcp-disconnect'); - if (meshtasticTcpSocket) { - console.debug('[IPC] meshtastic:tcp-disconnect'); - // Null before destroy so this teardown close is not reported as a live link drop. - const prev = meshtasticTcpSocket; - meshtasticTcpSocket = null; - clearLiveSessionMeter('meshtastic'); - prev.destroy(); - } +// ─── MeshCore / Meshtastic TCP bridges ───────────────────────────── +// Independent sockets (two createTcpBridge instances). writeMissing is the +// only protocol fork: MeshCore rejects; Meshtastic returns 'no-socket'. +registerTcpBridgeIpcHandlers({ + getMainWindow: () => mainWindow, + validateHost: validateHttpHost, }); // ─── Meshtastic HTTP bridge ───────────────────────────────────────── @@ -6704,16 +6358,6 @@ async function readBoundedArrayBuffer(response: Response, maxBytes: number): Pro } return merged.buffer; } -const MESHCORE_TCP_CONNECT_TIMEOUT_MS = 20_000; -/** Initial TCP keepalive probe delay for MeshCore companion sockets (ms). */ -const MESHCORE_TCP_KEEPALIVE_INITIAL_DELAY_MS = 30_000; -const MESHTASTIC_TCP_CONNECT_TIMEOUT_MS = 20_000; -/** Initial TCP keepalive probe delay for Meshtastic WiFi/TCP sockets (ms). */ -const MESHTASTIC_TCP_KEEPALIVE_INITIAL_DELAY_MS = 30_000; -/** Max Meshtastic TCP toRadio write payload (aligned with meshcore:tcp-write cap). */ -const MESHTASTIC_TCP_WRITE_MAX_BYTES = 256 * 1024; -/** Cap inbound Meshtastic TCP chunks before IPC fan-out (same as write max). */ -const MESHTASTIC_TCP_DATA_MAX_BYTES = MESHTASTIC_TCP_WRITE_MAX_BYTES; const CHAT_EXPORT_MAX_MESSAGES = 10_000; const DB_SAVE_NODE_PATH_MAX_BYTES = 16 * 1024; /** Max Meshtastic HTTP toRadio payload (aligned with meshcore:tcp-write cap). */ @@ -7194,28 +6838,7 @@ app.on('will-quit', (event) => { err instanceof Error ? err.message : err, ); // log-injection-ok internal library error during cleanup } - if (meshcoreTcpSocket) { - try { - meshcoreTcpSocket.destroy(); - } catch (err) { - console.debug( - '[main] TCP socket destroy during will-quit (ignored):', - err instanceof Error ? err.message : err, - ); // log-injection-ok internal Node.js socket error during cleanup - } - meshcoreTcpSocket = null; - } - if (meshtasticTcpSocket) { - try { - meshtasticTcpSocket.destroy(); - } catch (err) { - console.debug( - '[main] TCP socket destroy during will-quit (ignored):', - err instanceof Error ? err.message : err, - ); // log-injection-ok internal Node.js socket error during cleanup - } - meshtasticTcpSocket = null; - } + destroyRegisteredTcpBridgeSockets('TCP socket destroy during will-quit (ignored)'); stopPowerSaveBlocker(); nobleBleManager.releaseNobleProcessHandles(); tray?.destroy(); diff --git a/src/main/ipc/tcp-bridge.test.ts b/src/main/ipc/tcp-bridge.test.ts new file mode 100644 index 000000000..d92046ed5 --- /dev/null +++ b/src/main/ipc/tcp-bridge.test.ts @@ -0,0 +1,396 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { FakeSocket, sockets } = vi.hoisted(() => { + const sockets: FakeSocket[] = []; + class FakeSocket { + destroyed = false; + writableEnded = false; + readableEnded = false; + remoteAddress = '127.0.0.1'; + remotePort = 5000; + connectPort: number | null = null; + connectHost: string | null = null; + private readonly listeners = new Map void)[]>(); + private connectListener: (() => void) | undefined; + readonly setNoDelay = vi.fn(); + readonly setKeepAlive = vi.fn(); + readonly connect = vi.fn((port: number, host: string, cb?: () => void) => { + this.connectPort = port; + this.connectHost = host; + this.connectListener = cb; + return this; + }); + readonly write = vi.fn((_data: Uint8Array, cb?: (err?: Error) => void) => { + cb?.(); + return true; + }); + destroy = vi.fn(() => { + this.destroyed = true; + this.emit('close', false); + }); + + constructor() { + sockets.push(this); + } + + on(event: string, fn: (...args: unknown[]) => void): this { + const list = this.listeners.get(event) ?? []; + list.push(fn); + this.listeners.set(event, list); + return this; + } + + emit(event: string, ...args: unknown[]): boolean { + for (const fn of this.listeners.get(event) ?? []) fn(...args); + return true; + } + + completeConnect(): void { + this.connectListener?.(); + } + } + return { FakeSocket, sockets }; +}); + +vi.mock('net', () => ({ + default: { + Socket: FakeSocket, + }, +})); + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, +})); + +vi.mock('../validate-ipc-sender', () => ({ + assertIpcSender: vi.fn(), +})); + +vi.mock('../live-session-meter', () => ({ + clearLiveSessionMeter: vi.fn(), + noteLiveSessionData: vi.fn(), + noteLiveSessionWrite: vi.fn(), + resetLiveSessionMeter: vi.fn(), +})); + +vi.mock('../log-service', () => ({ + sanitizeLogMessage: (value: string) => value, + logDeviceConnection: vi.fn(), +})); + +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; +import { ipcMain } from 'electron'; + +import { noteLiveSessionData, noteLiveSessionWrite } from '../live-session-meter'; +import { assertIpcSender } from '../validate-ipc-sender'; +import { + createTcpBridge, + registerTcpBridgeIpcHandlers, + TCP_BRIDGE_CONNECT_TIMEOUT_MS, + TCP_BRIDGE_DATA_MAX_BYTES, + TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS, + TCP_BRIDGE_WRITE_MAX_BYTES, +} from './tcp-bridge'; + +const TCP_BRIDGE_SOURCE = readFileSync(join(__dirname, 'tcp-bridge.ts'), 'utf-8'); +const event = {} as IpcMainInvokeEvent; + +function validateHost(host: unknown): asserts host is string { + if (typeof host !== 'string' || host.length === 0) { + throw new Error('Invalid host'); + } +} + +function latestSocket(): InstanceType { + const sock = sockets[sockets.length - 1]; + if (!sock) throw new Error('expected a FakeSocket'); + return sock; +} + +describe('createTcpBridge', () => { + const send = vi.fn(); + const getMainWindow = (): BrowserWindow => + ({ webContents: { send } }) as unknown as BrowserWindow; + + beforeEach(() => { + sockets.length = 0; + send.mockReset(); + vi.clearAllMocks(); + vi.mocked(assertIpcSender).mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('rejects MeshCore writes without a socket and returns no-socket for Meshtastic', async () => { + const meshcore = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const meshtastic = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow, + validateHost, + }); + + await expect(meshcore.write(event, [1])).rejects.toThrow( + 'meshcore:tcp-write: no active socket', + ); + expect(console.warn).toHaveBeenCalledWith('[IPC] meshcore:tcp-write: no active socket'); + expect(meshtastic.write(event, [1])).toBe('no-socket'); + expect(console.debug).toHaveBeenCalledWith('[IPC] meshtastic:tcp-write: no active socket'); + }); + + it('keeps independent sockets so one protocol teardown does not affect the other', async () => { + const meshcore = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const meshtastic = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow, + validateHost, + }); + + const meshcoreConnect = meshcore.connect(event, '10.0.0.1', 5000); + const meshcoreSock = latestSocket(); + meshcoreSock.completeConnect(); + await meshcoreConnect; + + const meshtasticConnect = meshtastic.connect(event, '10.0.0.2', 4403); + const meshtasticSock = latestSocket(); + meshtasticSock.completeConnect(); + await meshtasticConnect; + + meshcore.disconnect(event); + await expect(meshtastic.write(event, [9])).resolves.toBeUndefined(); + expect(meshtasticSock.write).toHaveBeenCalled(); + await expect(meshcore.write(event, [9])).rejects.toThrow( + 'meshcore:tcp-write: no active socket', + ); + }); + + it('nulls the active ref before destroy so superseded closes do not emit disconnected', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + + const first = bridge.connect(event, '192.168.1.8', 5000); + const firstSock = latestSocket(); + firstSock.completeConnect(); + await first; + + const second = bridge.connect(event, '192.168.1.9', 5000); + const secondSock = latestSocket(); + expect(firstSock.destroy).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith('meshcore:tcp-disconnected'); + secondSock.completeConnect(); + await second; + + firstSock.emit('close', false); + expect(send).not.toHaveBeenCalledWith('meshcore:tcp-disconnected'); + + secondSock.emit('close', true); + expect(send).toHaveBeenCalledWith('meshcore:tcp-disconnected'); + }); + + it('does not emit tcp-data for oversized chunks and destroys the socket', async () => { + const bridge = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow, + validateHost, + }); + const connected = bridge.connect(event, '192.168.1.5', 4403); + const sock = latestSocket(); + sock.completeConnect(); + await connected; + + sock.emit('data', Buffer.alloc(TCP_BRIDGE_DATA_MAX_BYTES + 1)); + expect(sock.destroy).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith('meshtastic:tcp-data', expect.anything()); + expect(noteLiveSessionData).not.toHaveBeenCalled(); + }); + + it('meters only the active socket and still fans out data from a live session', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const connected = bridge.connect(event, '192.168.1.5', 5000); + const sock = latestSocket(); + sock.completeConnect(); + await connected; + + sock.emit('data', Buffer.from([1, 2, 3])); + expect(noteLiveSessionData).toHaveBeenCalledWith('meshcore'); + expect(send).toHaveBeenCalledWith('meshcore:tcp-data', new Uint8Array([1, 2, 3])); + }); + + it('enables TCP_NODELAY and keepalive, then times out a hung connect', async () => { + vi.useFakeTimers(); + const bridge = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow, + validateHost, + }); + const pending = bridge.connect(event, '192.168.1.5', 4403); + const sock = latestSocket(); + expect(sock.setNoDelay).toHaveBeenCalledWith(true); + expect(sock.setKeepAlive).toHaveBeenCalledWith(true, TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS); + const expectation = expect(pending).rejects.toThrow( + 'meshtastic:tcp-connect: connection timeout', + ); + await vi.advanceTimersByTimeAsync(TCP_BRIDGE_CONNECT_TIMEOUT_MS); + await expectation; + expect(sock.destroy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('returns no-socket for Meshtastic writes on a destroyed or classified-dead socket', async () => { + const bridge = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow, + validateHost, + }); + const connected = bridge.connect(event, '192.168.1.5', 4403); + const sock = latestSocket(); + sock.completeConnect(); + await connected; + + sock.destroyed = true; + expect(bridge.write(event, [1])).toBe('no-socket'); + + sock.destroyed = false; + sock.writableEnded = true; + expect(bridge.write(event, [1])).toBe('no-socket'); + + sock.writableEnded = false; + sock.write.mockImplementationOnce((_data: Uint8Array, cb?: (err?: Error) => void) => { + const err = new Error('write EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + cb?.(err); + return true; + }); + await expect(bridge.write(event, [1])).resolves.toBe('no-socket'); + }); + + it('rejects MeshCore write errors instead of mapping them to no-socket', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const connected = bridge.connect(event, '192.168.1.5', 5000); + const sock = latestSocket(); + sock.completeConnect(); + await connected; + + sock.write.mockImplementationOnce((_data: Uint8Array, cb?: (err?: Error) => void) => { + const err = new Error('write EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + cb?.(err); + return true; + }); + await expect(bridge.write(event, [1])).rejects.toThrow('write EPIPE'); + expect(noteLiveSessionWrite).not.toHaveBeenCalled(); + }); + + it('rejects invalid ports, hosts, and write payloads before touching the socket', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + await expect(bridge.connect(event, '192.168.1.5', 0)).rejects.toThrow('Invalid port'); + await expect(bridge.connect(event, '', 5000)).rejects.toThrow('Invalid host'); + expect(sockets).toHaveLength(0); + await expect(bridge.write(event, [256])).rejects.toThrow( + 'meshcore:tcp-write: byte values must be integers 0-255', + ); + await expect( + bridge.write(event, new Array(TCP_BRIDGE_WRITE_MAX_BYTES + 1).fill(1)), + ).rejects.toThrow('invalid or oversized payload'); + }); + + it('destroyForQuit tears down without emitting disconnected', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const connected = bridge.connect(event, '192.168.1.5', 5000); + const sock = latestSocket(); + sock.completeConnect(); + await connected; + + bridge.destroyForQuit('quitMainProcess TCP socket destroy (ignored)'); + expect(sock.destroy).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith('meshcore:tcp-disconnected'); + await expect(bridge.write(event, [1])).rejects.toThrow('meshcore:tcp-write: no active socket'); + }); + + it('does not null the active socket in the error handler so close can emit disconnected', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + const pending = bridge.connect(event, '192.168.1.5', 5000); + const sock = latestSocket(); + const boom = new Error('ECONNRESET'); + sock.emit('error', boom); + await expect(pending).rejects.toThrow('ECONNRESET'); + expect(send).not.toHaveBeenCalledWith('meshcore:tcp-disconnected'); + sock.emit('close', true); + expect(send).toHaveBeenCalledWith('meshcore:tcp-disconnected'); + }); +}); + +describe('registerTcpBridgeIpcHandlers', () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockClear(); + }); + + it('registers both protocol channel literals with assertIpcSender inside the shared handlers', () => { + registerTcpBridgeIpcHandlers({ + getMainWindow: () => null, + validateHost, + }); + const channels = vi.mocked(ipcMain.handle).mock.calls.map((call) => call[0]); + expect(channels).toEqual([ + 'meshcore:tcp-connect', + 'meshcore:tcp-write', + 'meshcore:tcp-disconnect', + 'meshtastic:tcp-connect', + 'meshtastic:tcp-write', + 'meshtastic:tcp-disconnect', + ]); + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'reject'"); + expect(TCP_BRIDGE_SOURCE).toContain("writeMissing: 'no-socket'"); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, connectChannel)'); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, writeChannel)'); + expect(TCP_BRIDGE_SOURCE).toContain('assertIpcSender(event, disconnectChannel)'); + }); +}); diff --git a/src/main/ipc/tcp-bridge.ts b/src/main/ipc/tcp-bridge.ts new file mode 100644 index 000000000..600eec740 --- /dev/null +++ b/src/main/ipc/tcp-bridge.ts @@ -0,0 +1,290 @@ +/** + * Shared MeshCore / Meshtastic TCP socket bridge. + * + * Two `createTcpBridge` instances keep independent sockets so both stacks can + * stay connected. The only intentional protocol fork is `writeMissing`: + * MeshCore rejects a missing socket; Meshtastic resolves `'no-socket'`. + */ + +import { type BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron'; +import net from 'net'; + +import { formatHostForSocket } from '../../shared/connectHost'; +import { + clearLiveSessionMeter, + noteLiveSessionData, + noteLiveSessionWrite, + resetLiveSessionMeter, +} from '../live-session-meter'; +import { logDeviceConnection, sanitizeLogMessage } from '../log-service'; +import { meshtasticTcpWriteErrorIsNoSocket } from '../meshtasticTcpWriteResult'; +import { assertIpcSender } from '../validate-ipc-sender'; + +export type TcpBridgeProtocol = 'meshcore' | 'meshtastic'; +export type TcpBridgeWriteMissing = 'reject' | 'no-socket'; + +/** Max TCP toRadio write payload (DoS guard). */ +export const TCP_BRIDGE_WRITE_MAX_BYTES = 256 * 1024; +/** Cap inbound TCP chunks before IPC fan-out (same as write max). */ +export const TCP_BRIDGE_DATA_MAX_BYTES = TCP_BRIDGE_WRITE_MAX_BYTES; +export const TCP_BRIDGE_CONNECT_TIMEOUT_MS = 20_000; +/** Initial TCP keepalive probe delay (ms). */ +export const TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS = 30_000; + +export interface TcpBridgeDeps { + protocol: TcpBridgeProtocol; + writeMissing: TcpBridgeWriteMissing; + getMainWindow: () => BrowserWindow | null; + validateHost: (host: unknown) => asserts host is string; +} + +export interface TcpBridgeHandlers { + connect: (event: IpcMainInvokeEvent, host: string, port: number) => Promise; + write: ( + event: IpcMainInvokeEvent, + bytes: number[], + ) => Promise<'no-socket' | undefined> | 'no-socket'; + disconnect: (event: IpcMainInvokeEvent) => void; + destroyForQuit: (logLabel: string) => void; +} + +export interface TcpBridgeRegisterDeps { + getMainWindow: () => BrowserWindow | null; + validateHost: (host: unknown) => asserts host is string; +} + +export function createTcpBridge(deps: TcpBridgeDeps): TcpBridgeHandlers { + const { protocol, writeMissing, getMainWindow } = deps; + const validateHost: (host: unknown) => asserts host is string = deps.validateHost; + let activeSocket: net.Socket | null = null; + + const connectChannel = `${protocol}:tcp-connect`; + const writeChannel = `${protocol}:tcp-write`; + const disconnectChannel = `${protocol}:tcp-disconnect`; + const dataChannel = `${protocol}:tcp-data`; + const disconnectedChannel = `${protocol}:tcp-disconnected`; + + const connect = (event: IpcMainInvokeEvent, host: string, port: number): Promise => { + assertIpcSender(event, connectChannel); + return new Promise((resolve, reject) => { + let settled = false; + const p = port; + if (!Number.isInteger(p) || p < 1 || p > 65535) { + reject(new Error('Invalid port')); + return; + } + try { + validateHost(host); + } catch (err) { + // catch-no-log-ok validation error forwarded to promise reject + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + if (activeSocket) { + // Null before destroy so the superseded socket's 'close' does not emit + // tcp-disconnected (renderer reconnect is driven by that event — #792). + const prev = activeSocket; + activeSocket = null; + clearLiveSessionMeter(protocol); + prev.destroy(); + } + const socketHost = formatHostForSocket(host); + const socket = new net.Socket(); + // MeshCore Open / official companion TCP clients use TCP_NODELAY; Node defaults can + // Nagle-batch small companion RPCs and OpenHop peers often FIN mid-init. + socket.setNoDelay(true); + socket.setKeepAlive(true, TCP_BRIDGE_KEEPALIVE_INITIAL_DELAY_MS); + activeSocket = socket; + const connectTimeout = setTimeout(() => { + if (settled) return; + settled = true; + if (activeSocket === socket) { + activeSocket = null; + clearLiveSessionMeter(protocol); + } + socket.destroy(); + reject(new Error(`${connectChannel}: connection timeout`)); + }, TCP_BRIDGE_CONNECT_TIMEOUT_MS); + socket.connect(p, socketHost, () => { + clearTimeout(connectTimeout); + console.debug(`[IPC] ${connectChannel} connected to`, sanitizeLogMessage(socketHost), p); + logDeviceConnection( + `transport=tcp stack=${protocol} host=${sanitizeLogMessage(socketHost)} port=${p}`, + ); + resetLiveSessionMeter(protocol); + if (!settled) { + settled = true; + resolve(); + } + }); + socket.on('data', (data) => { + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (chunk.length > TCP_BRIDGE_DATA_MAX_BYTES) { + console.warn( + `[IPC] ${dataChannel} oversized chunk (${chunk.length} > ${TCP_BRIDGE_DATA_MAX_BYTES}); dropping socket`, + ); + try { + socket.destroy(); + } catch (e) { + console.debug( + `[IPC] ${dataChannel} destroy after oversize ` + + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + } + return; + } + // Superseded sockets must not update the live session meter (#792 connect-replace). + if (activeSocket === socket) { + noteLiveSessionData(protocol); + } + getMainWindow()?.webContents.send(dataChannel, new Uint8Array(chunk)); + }); + socket.on('close', (hadError) => { + clearTimeout(connectTimeout); + // readableEnded=true after peer FIN; local destroy-before-null tear downs do not hit this + // branch as active (ref cleared first). Log fields help triage n7eal post-contacts hangs. + const remote = socket.remoteAddress + ? `${socket.remoteAddress}:${socket.remotePort ?? '?'}` + : 'unknown'; + console.debug( + `[IPC] ${protocol}:tcp socket closed`, + hadError ? '(hadError)' : '(clean)', + `remote=${sanitizeLogMessage(remote)}`, + `readableEnded=${socket.readableEnded}`, + `writableEnded=${socket.writableEnded}`, + ); + // Only notify when this socket is still the active bridge. connect/disconnect clear the + // ref before destroy(), so superseded closes must not look like a live link drop + // (renderer reconnect is driven by this event — see #792). + if (activeSocket === socket) { + activeSocket = null; + clearLiveSessionMeter(protocol); + getMainWindow()?.webContents.send(disconnectedChannel); + } + }); + socket.on('error', (err) => { + clearTimeout(connectTimeout); + console.error(`[IPC] ${connectChannel} error:`, sanitizeLogMessage(err.message)); + if (!settled) { + settled = true; + reject(err); + } + // Do not null the active socket here. Node fires 'error' before 'close' on ECONNRESET + // etc.; nulling early makes close's active-socket guard fail and swallows + // tcp-disconnected (renderer never reconnects). close owns that transition. + }); + }); + }; + + const write = ( + event: IpcMainInvokeEvent, + bytes: number[], + ): Promise<'no-socket' | undefined> | 'no-socket' => { + assertIpcSender(event, writeChannel); + if (!Array.isArray(bytes) || bytes.length > TCP_BRIDGE_WRITE_MAX_BYTES) { + return Promise.reject( + new Error( + `${writeChannel}: invalid or oversized payload (max ${TCP_BRIDGE_WRITE_MAX_BYTES} bytes)`, + ), + ); + } + // Validate each element is a valid byte value so Uint8Array coercion is not silently lossy. + if (!bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) { + return Promise.reject(new Error(`${writeChannel}: byte values must be integers 0-255`)); + } + if (!activeSocket) { + if (writeMissing === 'no-socket') { + // Expected reconnect race — resolve so Electron does not log handler [error]. + console.debug(`[IPC] ${writeChannel}: no active socket`); + return 'no-socket'; + } + const msg = `${writeChannel}: no active socket`; + console.warn(`[IPC] ${msg}`); + return Promise.reject(new Error(msg)); + } + const sock = activeSocket; + if (writeMissing === 'no-socket' && (sock.destroyed || sock.writableEnded)) { + console.debug(`[IPC] ${writeChannel}: no active socket`); + return 'no-socket'; + } + return new Promise<'no-socket' | undefined>((resolve, reject) => { + sock.write(new Uint8Array(bytes), (err) => { + if (err) { + if (writeMissing === 'no-socket' && meshtasticTcpWriteErrorIsNoSocket(sock, err)) { + console.debug(`[IPC] ${writeChannel}: no active socket`); + resolve('no-socket'); + return; + } + console.error(`[IPC] ${writeChannel} error:`, sanitizeLogMessage(err.message)); + reject(err); + } else { + // Ignore write completions from a superseded socket. + if (activeSocket === sock) { + noteLiveSessionWrite(protocol); + } + resolve(undefined); + } + }); + }); + }; + + const disconnect = (event: IpcMainInvokeEvent): void => { + assertIpcSender(event, disconnectChannel); + if (activeSocket) { + console.debug(`[IPC] ${disconnectChannel}`); + // Null before destroy so this teardown close is not reported as a live link drop. + const prev = activeSocket; + activeSocket = null; + clearLiveSessionMeter(protocol); + prev.destroy(); + } + }; + + const destroyForQuit = (logLabel: string): void => { + if (!activeSocket) return; + const prev = activeSocket; + activeSocket = null; + clearLiveSessionMeter(protocol); + try { + prev.destroy(); + } catch (err) { + console.debug(`[main] ${logLabel}:`, err instanceof Error ? err.message : err); // log-injection-ok internal Node.js socket error during cleanup + } + }; + + return { connect, write, disconnect, destroyForQuit }; +} + +const registeredBridges: TcpBridgeHandlers[] = []; + +/** Register both protocol bridges. Channel name literals keep check:ipc-contract aligned. */ +export function registerTcpBridgeIpcHandlers(deps: TcpBridgeRegisterDeps): void { + const meshcore = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow: deps.getMainWindow, + validateHost: deps.validateHost, + }); + ipcMain.handle('meshcore:tcp-connect', meshcore.connect); + ipcMain.handle('meshcore:tcp-write', meshcore.write); + ipcMain.handle('meshcore:tcp-disconnect', meshcore.disconnect); + + const meshtastic = createTcpBridge({ + protocol: 'meshtastic', + writeMissing: 'no-socket', + getMainWindow: deps.getMainWindow, + validateHost: deps.validateHost, + }); + ipcMain.handle('meshtastic:tcp-connect', meshtastic.connect); + ipcMain.handle('meshtastic:tcp-write', meshtastic.write); + ipcMain.handle('meshtastic:tcp-disconnect', meshtastic.disconnect); + + registeredBridges.push(meshcore, meshtastic); +} + +/** Destroy both protocol sockets on quit / will-quit. */ +export function destroyRegisteredTcpBridgeSockets(logLabel: string): void { + for (const bridge of registeredBridges) { + bridge.destroyForQuit(logLabel); + } +} From df4207bdb95e9dc69fb174cbb1ec70cb607ab646 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 12:13:09 +0000 Subject: [PATCH 2/2] fix: settle superseded in-flight TCP connect invokes Reject the pending tcp-connect promise on close when the socket never connected, so a connect-replace cannot hang the first IPC. --- src/main/ipc/tcp-bridge.test.ts | 28 ++++++++++++++++++++++++++-- src/main/ipc/tcp-bridge.ts | 6 ++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/tcp-bridge.test.ts b/src/main/ipc/tcp-bridge.test.ts index d92046ed5..a82fa93fa 100644 --- a/src/main/ipc/tcp-bridge.test.ts +++ b/src/main/ipc/tcp-bridge.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { FakeSocket, sockets } = vi.hoisted(() => { const sockets: FakeSocket[] = []; @@ -126,6 +126,10 @@ describe('createTcpBridge', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('rejects MeshCore writes without a socket and returns no-socket for Meshtastic', async () => { const meshcore = createTcpBridge({ protocol: 'meshcore', @@ -207,6 +211,27 @@ describe('createTcpBridge', () => { expect(send).toHaveBeenCalledWith('meshcore:tcp-disconnected'); }); + it('rejects a still-pending connect when a later connect supersedes it', async () => { + const bridge = createTcpBridge({ + protocol: 'meshcore', + writeMissing: 'reject', + getMainWindow, + validateHost, + }); + + const first = bridge.connect(event, '192.168.1.8', 5000); + const firstSock = latestSocket(); + const second = bridge.connect(event, '192.168.1.9', 5000); + const secondSock = latestSocket(); + + await expect(first).rejects.toThrow('meshcore:tcp-connect: closed before connect'); + expect(firstSock.destroy).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith('meshcore:tcp-disconnected'); + + secondSock.completeConnect(); + await second; + }); + it('does not emit tcp-data for oversized chunks and destroys the socket', async () => { const bridge = createTcpBridge({ protocol: 'meshtastic', @@ -260,7 +285,6 @@ describe('createTcpBridge', () => { await vi.advanceTimersByTimeAsync(TCP_BRIDGE_CONNECT_TIMEOUT_MS); await expectation; expect(sock.destroy).toHaveBeenCalled(); - vi.useRealTimers(); }); it('returns no-socket for Meshtastic writes on a destroyed or classified-dead socket', async () => { diff --git a/src/main/ipc/tcp-bridge.ts b/src/main/ipc/tcp-bridge.ts index 600eec740..9153255a6 100644 --- a/src/main/ipc/tcp-bridge.ts +++ b/src/main/ipc/tcp-bridge.ts @@ -161,6 +161,12 @@ export function createTcpBridge(deps: TcpBridgeDeps): TcpBridgeHandlers { clearLiveSessionMeter(protocol); getMainWindow()?.webContents.send(disconnectedChannel); } + // Connect-replace / timeout destroy can close a socket that never connected. + // Clearing the timeout above would otherwise leave the IPC invoke pending. + if (!settled) { + settled = true; + reject(new Error(`${connectChannel}: closed before connect`)); + } }); socket.on('error', (err) => { clearTimeout(connectTimeout);