Summary
SerialConnection.onDataReceived() treats any 0x3e byte as the start of a frame and accepts the following two bytes as the frame length without validating it. If a > appears anywhere in the serial stream that is not a real frame header — boot output, a debug print, a truncated frame — the parser can compute an impossibly large length and then wait for bytes that never arrive.
Every real frame received afterwards queues up behind it and is never parsed. The parser does not recover on its own.
Impact
Once desynced, the client goes quiet. No error is raised, rx stops firing, and the promise-based calls (getSelfInfo(), deviceQuery(), getChannels()) simply never settle, since most of them have no timeout. From the user's side the app appears to connect and then hang indefinitely.
I hit this while testing a client against a Heltec V3 and V4 over USB serial. It is not a hypothetical parse quirk — a single stray byte is enough, and it is permanent.
Reproduction
Save as repro_desync.mjs in the root of a meshcore.js checkout and run node repro_desync.mjs:
import SerialConnection from "./src/connection/serial_connection.js";
class FakeSerialConnection extends SerialConnection {
async write() {}
}
// a well formed incoming frame: 0x3e, uint16le length, payload
function frame(payload) {
return [0x3e, payload.length & 0xff, (payload.length >> 8) & 0xff, ...payload];
}
const flush = () => new Promise((r) => setTimeout(r, 20));
const validFrame = frame([0x00, 0x01, 0x02, 0x03]);
async function run(label, bytes) {
const conn = new FakeSerialConnection();
let framesReceived = 0;
conn.on("rx", () => framesReceived++);
await conn.onDataReceived(bytes);
await flush();
console.log(`${label}\n frames received: ${framesReceived}\n bytes stuck in readBuffer: ${conn.readBuffer.length}\n`);
}
await run("control, frame with no preceding noise:", validFrame);
const noise = [...Buffer.from("boot> ready\r\n", "utf8")];
await run('with "boot> ready" noise in front of the same frame:', [...noise, ...validFrame]);
// once desynced it stays desynced
const conn = new FakeSerialConnection();
let later = 0;
conn.on("rx", () => later++);
await conn.onDataReceived(noise);
for (let i = 0; i < 20; i++) await conn.onDataReceived(validFrame);
await flush();
console.log(`after the noise, 20 more valid frames were fed in:\n frames received: ${later}\n bytes stuck in readBuffer: ${conn.readBuffer.length}`);
Output
control, frame with no preceding noise:
frames received: 1
bytes stuck in readBuffer: 0
with "boot> ready" noise in front of the same frame:
frames received: 0
bytes stuck in readBuffer: 16
after the noise, 20 more valid frames were fed in:
frames received: 0
bytes stuck in readBuffer: 149
Root cause
In src/connection/serial_connection.js:
const frameLength = frameHeader.readUInt16LE();
if(!frameLength){
// unexpected byte, lets skip it and try again
this.readBuffer = this.readBuffer.slice(1);
continue;
}
const requiredLength = frameHeaderLength + frameLength;
if(this.readBuffer.length < requiredLength){
break;
}
Zero is rejected, but any other value up to 0xFFFE is accepted. In the example above the two bytes after > are " r", so readUInt16LE() returns 0x7220 = 29216. The parser then waits for 29219 bytes and breaks out of the loop on every subsequent call.
Suggested fix
The firmware defines MAX_FRAME_SIZE 176 in src/helpers/BaseSerialInterface.h, so any length beyond that cannot be a real frame and can be rejected the same way a zero length already is:
const frameLength = frameHeader.readUInt16LE();
if(!frameLength || frameLength > MAX_FRAME_SIZE){
// not a real frame header, skip this byte and resync
this.readBuffer = this.readBuffer.slice(1);
continue;
}
That turns a permanent desync into the loss of at most the bytes up to the next genuine header.
A cap on readBuffer growth would complement it, so that a desync cannot accumulate unbounded memory while it waits for a frame that will never complete.
Environment
@liamcottle/meshcore.js 1.15.0, same code on master
- Web Serial in Chrome, Windows 11
- Heltec V3 (CP210x) and Heltec V4 (native USB CDC), companion radio USB firmware
Summary
SerialConnection.onDataReceived()treats any0x3ebyte as the start of a frame and accepts the following two bytes as the frame length without validating it. If a>appears anywhere in the serial stream that is not a real frame header — boot output, a debug print, a truncated frame — the parser can compute an impossibly large length and then wait for bytes that never arrive.Every real frame received afterwards queues up behind it and is never parsed. The parser does not recover on its own.
Impact
Once desynced, the client goes quiet. No error is raised,
rxstops firing, and the promise-based calls (getSelfInfo(),deviceQuery(),getChannels()) simply never settle, since most of them have no timeout. From the user's side the app appears to connect and then hang indefinitely.I hit this while testing a client against a Heltec V3 and V4 over USB serial. It is not a hypothetical parse quirk — a single stray byte is enough, and it is permanent.
Reproduction
Save as
repro_desync.mjsin the root of ameshcore.jscheckout and runnode repro_desync.mjs:Output
Root cause
In
src/connection/serial_connection.js:Zero is rejected, but any other value up to
0xFFFEis accepted. In the example above the two bytes after>are" r", soreadUInt16LE()returns0x7220= 29216. The parser then waits for 29219 bytes andbreaks out of the loop on every subsequent call.Suggested fix
The firmware defines
MAX_FRAME_SIZE 176insrc/helpers/BaseSerialInterface.h, so any length beyond that cannot be a real frame and can be rejected the same way a zero length already is:That turns a permanent desync into the loss of at most the bytes up to the next genuine header.
A cap on
readBuffergrowth would complement it, so that a desync cannot accumulate unbounded memory while it waits for a frame that will never complete.Environment
@liamcottle/meshcore.js1.15.0, same code onmaster