Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,14 @@ IPv6 addresses work for Meshtastic Wi‑Fi, MeshCore TCP, and Reticulum RNode Wi

**Address examples:** `192.168.1.10:4403`, `meshtastic.local:4403`, `[fd00::1]:4403`.

### Connection panel Link quality (TCP) shows "—" or unexpected latency

**Cause:** For **Meshtastic WiFi/TCP** and **MeshCore TCP/IP SoftAP**, the Connection panel signal bars reflect **live-session responsiveness** — an EWMA of write→first-data delay on the already-open TCP socket — not a separate connect probe. Bars may show **"—"** until traffic has produced a sample, or after ~2 minutes without a completed sample (covers idle heartbeat gaps). Meshtastic **WiFi/HTTP** still uses a `/json/report` RTT probe (separate from the TCP session). Reticulum hub/RMAP rows still use a short-lived TCP connect probe (different risk profile).

**Why not a second TCP connect?** Probing the same `host:port` as the live session every few seconds can RST ESP32/lwIP-class devices (see PR discussion around competing connections).

**Fix:** Exercise the link (chat, NodeDB traffic, companion RPCs). If bars stay empty while the session is healthy, that is expected during idle gaps; reconnect if the session itself drops.

### Meshtastic HTTP fails immediately with "Invalid host format"

**Cause:** Builds before v5.21.x validated the hostname incorrectly when the address included a port (`192.168.1.10:443`), rejecting every HTTP connect.
Expand Down
26 changes: 26 additions & 0 deletions src/main/index.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,31 @@ describe('Host link quality IPC (source contract)', () => {
expect(INDEX_SOURCE).toContain("webContents.send('noble-ble-link-rssi'");
expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:probeHttpRtt'");
expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:probeTcpRtt'");
expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:getSessionMeter'");
});

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')");
// 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(INDEX_SOURCE).toMatch(
/if \(meshcoreTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshcore'\)/,
);
expect(INDEX_SOURCE).toMatch(
/if \(meshtasticTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshtastic'\)/,
);
});
});

Expand All @@ -399,6 +424,7 @@ describe('Host link quality preload surface (source contract)', () => {
expect(PRELOAD_SOURCE).toContain('hostLink:');
expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeHttpRtt'");
expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeTcpRtt'");
expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:getSessionMeter'");
});
});

Expand Down
34 changes: 24 additions & 10 deletions src/main/index.ipc-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => {
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 + 1200);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400);
// Null the active ref before destroy so the superseded close does not emit
// meshtastic:tcp-disconnected against a healthy replacement (#792).
expect(handlerBody).toMatch(
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/,
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/,
);
});

Expand All @@ -131,7 +131,7 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => {
expect(handlerIdx).toBeGreaterThan(-1);
const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx);
expect(closeIdx).toBeGreaterThan(handlerIdx);
const closeBody = INDEX_SOURCE.slice(closeIdx, closeIdx + 600);
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).
Expand All @@ -146,9 +146,9 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => {
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 + 400);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500);
expect(handlerBody).toMatch(
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/,
/const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/,
);
});

Expand Down Expand Up @@ -393,16 +393,16 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => {
it('nulls meshcoreTcpSocket before destroy on connect-replace and disconnect (PR #792)', () => {
const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'");
expect(connectIdx).toBeGreaterThan(-1);
const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1200);
const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1400);
expect(connectBody).toMatch(
/const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/,
/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 + 400);
const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 500);
expect(disconnectBody).toMatch(
/const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/,
/const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*clearLiveSessionMeter\('meshcore'\);\s*prev\.destroy\(\)/,
);
});

Expand Down Expand Up @@ -433,6 +433,19 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => {
});
});

describe('hostLink:getSessionMeter validation (source contract)', () => {
it('rejects protocols other than meshtastic/meshcore', () => {
const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('hostLink:getSessionMeter'");
expect(handlerIdx).toBeGreaterThan(-1);
const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400);
expect(handlerBody).toContain("assertIpcSender(event, 'hostLink:getSessionMeter')");
expect(handlerBody).toContain("protocol !== 'meshtastic'");
expect(handlerBody).toContain("protocol !== 'meshcore'");
expect(handlerBody).toContain("throw new Error('Invalid protocol')");
expect(handlerBody).toContain('snapshotLiveSessionMeter(');
});
});

// ─── meshtastic:tcp-connect hostname validation ──────────────────────

describe('meshtastic:tcp-connect hostname validation (source contract)', () => {
Expand Down Expand Up @@ -568,6 +581,7 @@ describe('privileged IPC sender validation (source contract)', () => {
'meshtastic:tcp-connect',
'meshtastic:tcp-write',
'meshtastic:tcp-disconnect',
'hostLink:getSessionMeter',
'noble-ble-connect',
'noble-ble-disconnect',
'notify:message',
Expand Down Expand Up @@ -637,7 +651,7 @@ describe('privileged IPC sender validation (source contract)', () => {
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,1200}meshtastic:tcp-connect: connection timeout/,
/meshtastic:tcp-connect[\s\S]{0,1800}meshtastic:tcp-connect: connection timeout/,
);
});

Expand Down
49 changes: 47 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ import {
linuxWebBluetoothDeviceSelection,
} from './linuxWebBluetoothDeviceSelection';
import { listMeshcoreDmPeersFromDb, listMeshtasticDmPeersFromDb } from './listDmPeers';
import {
clearLiveSessionMeter,
noteLiveSessionData,
noteLiveSessionWrite,
resetLiveSessionMeter,
snapshotLiveSessionMeter,
} from './live-session-meter';
import {
clearLogFile,
exportLogTo,
Expand Down Expand Up @@ -6123,6 +6130,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
// meshcore:tcp-disconnected (renderer reconnect is driven by that event — #792).
const prev = meshcoreTcpSocket;
meshcoreTcpSocket = null;
clearLiveSessionMeter('meshcore');
prev.destroy();
}
const socketHost = formatHostForSocket(host);
Expand All @@ -6135,7 +6143,10 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
const connectTimeout = setTimeout(() => {
if (settled) return;
settled = true;
if (meshcoreTcpSocket === socket) meshcoreTcpSocket = null;
if (meshcoreTcpSocket === socket) {
meshcoreTcpSocket = null;
clearLiveSessionMeter('meshcore');
}
socket.destroy();
reject(new Error('meshcore:tcp-connect: connection timeout'));
}, MESHCORE_TCP_CONNECT_TIMEOUT_MS);
Expand All @@ -6145,6 +6156,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
logDeviceConnection(
`transport=tcp stack=meshcore host=${sanitizeLogMessage(socketHost)} port=${p}`,
);
resetLiveSessionMeter('meshcore');
if (!settled) {
settled = true;
resolve();
Expand All @@ -6166,6 +6178,10 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
}
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) => {
Expand All @@ -6187,6 +6203,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => {
// (renderer reconnect is driven by this event — see #792).
if (meshcoreTcpSocket === socket) {
meshcoreTcpSocket = null;
clearLiveSessionMeter('meshcore');
mainWindow?.webContents.send('meshcore:tcp-disconnected');
}
});
Expand Down Expand Up @@ -6229,6 +6246,10 @@ ipcMain.handle('meshcore:tcp-write', (event, bytes: number[]) => {
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();
}
});
Expand All @@ -6242,6 +6263,7 @@ ipcMain.handle('meshcore:tcp-disconnect', (event) => {
// Null before destroy so this teardown close is not reported as a live link drop.
const prev = meshcoreTcpSocket;
meshcoreTcpSocket = null;
clearLiveSessionMeter('meshcore');
prev.destroy();
}
});
Expand Down Expand Up @@ -6272,6 +6294,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
// meshtastic:tcp-disconnected (renderer reconnect is driven by that event — #792).
const prev = meshtasticTcpSocket;
meshtasticTcpSocket = null;
clearLiveSessionMeter('meshtastic');
prev.destroy();
}
const socketHost = formatHostForSocket(host);
Expand All @@ -6282,7 +6305,10 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
const connectTimeout = setTimeout(() => {
if (settled) return;
settled = true;
if (meshtasticTcpSocket === socket) meshtasticTcpSocket = null;
if (meshtasticTcpSocket === socket) {
meshtasticTcpSocket = null;
clearLiveSessionMeter('meshtastic');
}
socket.destroy();
reject(new Error('meshtastic:tcp-connect: connection timeout'));
}, MESHTASTIC_TCP_CONNECT_TIMEOUT_MS);
Expand All @@ -6292,6 +6318,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
logDeviceConnection(
`transport=tcp stack=meshtastic host=${sanitizeLogMessage(socketHost)} port=${p}`,
);
resetLiveSessionMeter('meshtastic');
if (!settled) {
settled = true;
resolve();
Expand All @@ -6313,6 +6340,10 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
}
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) => {
Expand All @@ -6323,6 +6354,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) =>
// (renderer reconnect is driven by this event — see #792).
if (meshtasticTcpSocket === socket) {
meshtasticTcpSocket = null;
clearLiveSessionMeter('meshtastic');
mainWindow?.webContents.send('meshtastic:tcp-disconnected');
}
});
Expand Down Expand Up @@ -6365,6 +6397,10 @@ ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => {
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();
}
});
Expand All @@ -6378,6 +6414,7 @@ ipcMain.handle('meshtastic:tcp-disconnect', (event) => {
// Null before destroy so this teardown close is not reported as a live link drop.
const prev = meshtasticTcpSocket;
meshtasticTcpSocket = null;
clearLiveSessionMeter('meshtastic');
prev.destroy();
}
});
Expand Down Expand Up @@ -6516,6 +6553,14 @@ ipcMain.handle('hostLink:probeTcpRtt', async (event, host: unknown, port: unknow
return probeTcpRttMs(host, port as number);
});

ipcMain.handle('hostLink:getSessionMeter', (event, protocol: unknown) => {
assertIpcSender(event, 'hostLink:getSessionMeter');
if (protocol !== 'meshtastic' && protocol !== 'meshcore') {
throw new Error('Invalid protocol');
}
return snapshotLiveSessionMeter(protocol);
});

ipcMain.handle('http:connect', async (event, host: unknown, tls: unknown) => {
if (!validateIpcSender(event)) throw new Error('http:connect: unauthorized sender');
validateHttpHost(host);
Expand Down
Loading