Skip to content

Fragmentation and fragment recovery

Large logical messages are split into multiple radio frames sharing:

  • messageId16-bit identifier unique per (srcId, logical_message) within a reuse window (see Packet layout).
  • fragment (optional 2-byte block when HAS_FRAGMENT) — fragmentIndex + moreFragments. For the current wire revision this block is normatively aligned with PAYLOAD_BODY_V2 currentIndex / totalCount (not a hint).

Stateless chunk context (PAYLOAD_BODY_V2)

For file / blob transfers, LowMesh uses payloadType = 0x04 (PAYLOAD_BODY_V2) — see Payloads. PAYLOAD_BODY_V2 is a payload type identifier, not a public API version label.

Every such frame carries its own:

  • currentIndex — which chunk this is (0-based)
  • totalCount — how many chunks the logical object has
  • chunkLen + chunkBytes — the data for this chunk

There is no separate metadata-only payload that carries fragmentTotal (or totalCount) alone for the transfer. Reassembly MUST read currentIndex / totalCount (2 bytes LE each) from each received PAYLOAD_BODY_V2 body. Implementations MUST NOT require a prior “announcement” frame for the total count.

Consistency: For a given (channelHash, srcId, sessionId, messageId) logical transfer, totalCount MUST be identical on every chunk; currentIndex MUST be unique per chunk. Violations → drop the session.

Encoding fragment block (16-bit)

Packing (used for duplicate suppression, CCM nonce fragmentWord, and ordering):

BitsField
15moreFragments1 if more after this
14–10reserved (MUST be 0)
9–0fragmentIndex (0..1023)

Current-revision limits (normative)

For the current wire format:

  • totalCount MUST be 1..1024.
  • currentIndex MUST be 0..totalCount-1.
  • Therefore currentIndex MUST be 0..1023.
  • Every multi-frame logical transfer MUST set HAS_FRAGMENT on every frame of that transfer.
  • For PAYLOAD_BODY_V2, header fragmentIndex MUST equal currentIndex.
  • Header moreFragments MUST equal:
text
(currentIndex + 1 < totalCount)

If these values disagree, the receiver MUST reject the fragment/session (drop before application reassembly; log DROP_BAD_TOTAL_COUNT, DROP_FRAGMENT_INDEX_MISMATCH, or DROP_BAD_MORE_FRAGMENTS as appropriate).

fragmentWord participates in the CCM nonce. Implementations MUST NOT wrap, reuse, or treat the 10-bit fragmentIndex as a hint when currentIndex would not fit. Supporting more than 1024 chunks requires a future wire-format/nonce revision and MUST NOT be implemented by wrapping or reusing the 10-bit fragment index.

Legacy / other payload types: Short chat (TEXT_UTF8) MAY still use the header fragment field alone for multi-frame text without PAYLOAD_BODY_V2. The same fragmentIndex 0..1023 and HAS_FRAGMENT rules apply; there is no out-of-range index.

Example: 10 chunks (PAYLOAD_BODY_V2)

Logical file message with messageId = 0x0099 (example), totalCount = 10 on every frame:

FramecurrentIndexmoreFragments (header)
1/1001
1
10/1090

Receivers assemble when:

  • All indices 0..totalCount-1 present (values taken from each chunk’s PAYLOAD_BODY_V2), or
  • Application deadline hits and triggers recovery.

Lost fragment request

FRAG_NACK (payloadType = 0x07)

FieldSizeDescription
targetSrcId4Original sender (LE nodeId32)
targetSessionId4Original sessionId (LE uint32) from the base header
targetMessageId2messageId of the transfer (LE uint16)
totalCount2totalCount from PAYLOAD_BODY_V2 (LE uint16)
rangeCount1Number of ranges in missingRanges
missingRanges4 × rangeCountRepeated pairs: startIndex (2 LE), endIndex (2 LE) inclusive

Each range lists inclusive currentIndex values still missing from the reassembly window. endIndex MUST be ≥ startIndex.

Rules:

  • innerBodyLen MUST equal 13 + 4 × rangeCount.
  • Full transfer identity: (channelHash, targetSrcId, targetSessionId, targetMessageId).
  • totalCount MUST match the value seen on every chunk of that session.

The recipient (or any node caching content) SHOULD respond with retransmits of the corresponding PAYLOAD_BODY_V2 frames.

Interaction with routing

  • All fragments share messageId and sessionId; duplicate suppression uses (channelHash, srcId, sessionId, messageId, fragmentWord) for the header slot, and SHOULD also key PAYLOAD_BODY_V2 sessions by (channelHash, srcId, sessionId, messageId) with totalCount validation.
  • Floods MAY deliver chunks out of order; reassembly buffers (when used) MUST be keyed by (channelHash, srcId, sessionId, messageId).
  • Proxy / tethered Clients MAY hold no full-file buffer for large third-party transfers — see Reassembly profiles below and USB & BLE hex API.

Reassembly profiles: where state lives

Large logical messages can be reassembled on the radio node or on a tethered host, depending on hardware profile. Both profiles use FRAG_NACK / timers on the same mesh; totalCount / currentIndex come from each PAYLOAD_BODY_V2 frame, not from a separate metadata packet.

Profile A: Host-Tethered Streaming (proxy / companion)

Typical hardware: RAM-constrained Client MCUs (e.g. nRF52840-class) with USB and/or BLE attached to a phone or PC.

Proxy role (normative)

When operating in this profile for large chunked transfers (PAYLOAD_BODY_V2 intended for the companion), the device MUST NOT buffer a full reconstructed file in local RAM for third-party traffic. It MUST act as a transparent pipe:

  1. Receive the RF frame, apply duplicate suppression and policy checks with bounded internal state.
  2. Immediately stream the relevant bytes to the connected companion using FRAME_CAPTURE (0x02) — see USB & BLE hex API.

The MCU does not own sliding-window reassembly or gap lists for that transfer; the host reads currentIndex / totalCount from each forwarded frame.

Host-side state machine (companion)

When the Client is in proxy mode, the host app becomes responsible for:

  • Sliding-window reassembly using currentIndex / totalCount from each PAYLOAD_BODY_V2.
  • Detecting missing indices within the window and originating FRAG_NACK mesh frames.

The microcontroller’s job is to accept FRAG_NACK frames from the host (via FRAME_INJECT, 0x01) and route them back onto the radio per normal forwarding rules — without maintaining a full local reassembly buffer for the referenced transfer.

Companion recovery timing (recommended): Hosts SHOULD issue FRAG_NACK as soon as a missing index blocks forward progress in the sliding window (timeouts / reorder limits), not only after the sender has finished the whole transfer. End-of-transfer sweeps MAY still run as a secondary check.

Validation without full buffers (proxy)

Even in proxy mode, firmware MUST still validate cheap invariants before forwarding (and MUST drop abusive frames without allocating hole vectors):

  • currentIndex ≥ totalCount
  • totalCount == 0 or totalCount > 1024 / totalCount > nFragMax (use the same published nFragMax cap as the local-reassembly profile)
  • header fragmentIndex != currentIndex
  • incorrect moreFragments
  • Rate limits on new (channelHash, srcId, sessionId, messageId) sessions toward the host path (same rationale as plaintext abuse below)

Profile B: Local reassembly (gateway / capable repeater)

Typical hardware: Nodes with sufficient RAM and/or NVM (e.g. ESP32-based gateways, dedicated repeaters with store-and-forward).

Such devices MAY perform full local reassembly on the MCU, subject to explicit limits. They are not required to use Host-Tethered Streaming for every transfer.

Reassembly memory safety (local reassembly profile, anti-OOM)

Because totalCount can be large and can imply a big reconstructed object, receivers in Profile B MUST NOT allocate buffers proportional to “max index seen” or “max declared total” without explicit local limits.

Normative limits (receiver, Profile B)

Each implementation MUST publish (in firmware docs / operator notes) and enforce:

  • nFragMax: maximum supported totalCount for any single logical message (MUST be ≤ 1024; recommended default 256 unless the product has proven RAM headroom).
  • bMsgMax: maximum reconstructed logical message size in bytes (recommended default 32 KiB for handheld-class devices; gateways MAY be higher).
  • nAsmMax: maximum number of concurrent in-progress reassembly sessions (recommended default 2–4 on MCUs; gateways MAY be higher).

If an inbound frame violates these limits, the receiver MUST drop the reassembly state for that (channelHash, srcId, sessionId, messageId) and MUST NOT grow buffers to satisfy the declared span.

Sparse / malicious chunk patterns (Profile B)

Receivers MUST treat the following as invalid / hostile for reassembly (drop the session; do not allocate “holes”):

  • currentIndex ≥ totalCount
  • totalCount == 0 or totalCount > 1024 / totalCount > nFragMax
  • header fragmentIndex != currentIndex
  • incorrect moreFragments
  • attempted wraparound/reuse of the 10-bit fragment index
  • Any claim implying totalCount × maxChunkBytes > bMsgMax (use worst-case chunk sizing if the sender is untrusted)
  • Hole beyond window: if using a sliding window (recommended), receiving a chunk whose index is far ahead of the lowest missing index without intermediate progress MUST be dropped (prevents “allocate 1023 after chunk 0” patterns).

Receivers SHOULD use sliding-window reassembly:

  • Keep only a fixed window of next expected chunks in RAM (e.g. 8–32), not the entire 0..totalCount-1 vector.
  • Validate monotonicity and bounds against bMsgMax before accepting storage.
  • If the window cannot advance due to a missing index, use FRAG_NACK / timers; if still stuck, purge the session.

Encrypted vs plaintext channel note

On every channel, fragment bodies are protected by AES-CCM-4 once decrypted; adversaries without the key cannot forge valid MICs. Implementations SHOULD still rate-limit new (channelHash, srcId, sessionId, messageId) reassembly sessions per peer (Profile B) and apply rate / nFragMax checks on Profile A proxy paths — see Security below.

Pseudocode: receiver (Profile B — local reassembly)

Profile A proxy firmware replaces reassembly.getOrCreateLimited with serialize-to-host (FRAME_CAPTURE) plus tiny dedupe state; see USB & BLE hex API.

text
onFrame(f):
  key = (f.channelHash, f.srcId, f.sessionId, f.messageId)
  if shouldIgnoreOrDrop(f):
    return

  if f.payload is PAYLOAD_BODY_V2:
    idx = f.payload.currentIndex
    tot = f.payload.totalCount
  else:
    // legacy / other types: derive idx/tot from header + app rules
    ...

  buf = reassembly.getOrCreateLimited(key)
  if buf is null:
    return // at nAsmMax or policy denies new sessions

  if not buf.acceptChunk(idx, tot, f.payload.chunkBytes):
    purge(key)
    return

  if buf.completeWithinLimits():
    deliver(toUpperLayer(buf))
    purge(key)
  else if buf.stale():
    send(FRAG_NACK(...))
    purge(key)

Security

On encrypted channels, fragment requests are still authenticated by the channel key, preventing blind resource exhaustion from outsiders.

RAM exhaustion: even with authentication, a compromised member or buggy peer can still attempt sparse chunk patterns. Profile B receivers MUST enforce the Reassembly memory safety limits above so reassembly cannot allocate unbounded RAM. Profile A Clients MUST still enforce bounded queues toward USB/BLE and drop under backpressure (see USB & BLE hex API) rather than buffering unbounded RF traffic.

LowMeshOS — always open-source mesh protocol documentation