Skip to content

Serial frame parser desyncs permanently on a stray 0x3e in the stream #39

Description

@JustJoe8675309

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions