v1.0.12: Fix link MTU clamping, echo-back prevention, oversized frame detection

MTU Clamping (Bug 8a): Clamp link MTU signalling in LINKREQUEST packets
when forwarding through transport node, matching Python reference impl.
Without this, TCP endpoints negotiate 8192-byte segments that exceed the
V3's 1064-byte HDLC buffer, causing silent truncation and permanent
resource transfer stalls at ~70%.

Fixed MTU declaration (Bug 8b): Set FIXED_MTU=true on TcpInterface so
Transport uses HW_MTU for clamping decisions.

Oversized frame detection (Bug 8c): Track truncated HDLC frames and
drop them with a diagnostic log instead of silently delivering corrupt
data to Transport.

Echo-back prevention (v1.0.10): Track which TCP client originated each
inbound frame and skip that client in send_outgoing() to prevent
flooding TCP buffers.

Register local client interface: Enable Transport forwarding of
announces, link packets, and proofs to TCP clients.

Document all discovered microReticulum bugs in MICRORETICULUM_BUGS.md.
This commit is contained in:
James L
2026-02-28 12:49:30 -05:00
parent 4e5d4ee8ad
commit 1122e9a0ee
5 changed files with 259 additions and 5 deletions

View File

@@ -273,3 +273,151 @@ are unaffected because they bypass the hashlist check in `packet_filter`.
the Python reference implementation. Add `_packet_hashlist.insert()` inside
the link transport forwarding block after direction is confirmed.
---
## 8. Missing Link MTU Clamping — 70% Resource Transfer Stall (CRITICAL)
### Summary
**Status:** FIXED (v1.0.12, 2026-02-28)
`Transport.cpp` did not clamp the link MTU when forwarding `LINKREQUEST` packets through the transport node. The Python reference implementation (`Transport.py` lines 14581480) performs this clamping to ensure the negotiated link MTU does not exceed the capacity of any intermediate hop's interface.
### Bug 8a — LINKREQUEST forwarded without MTU clamping
**File:** `Transport.cpp`, all three LINKREQUEST forwarding paths:
1. Standard transport forwarding (next-hop routing, ~line 1729)
2. Boundary mode: local → backbone (~line 1875)
3. Boundary mode: backbone → local (~line 1959)
**Code (before fix):**
```cpp
if (packet.packet_type() == Type::Packet::LINKREQUEST) {
// ... creates link_entry, inserts into _link_table ...
// MTU signalling bytes in new_raw are forwarded UNCHANGED
}
```
The Python reference (`Transport.py` lines 14581480) does:
```python
path_mtu = RNS.Link.mtu_from_lr_packet(packet)
if path_mtu:
nh_mtu = outbound_interface.HW_MTU
ph_mtu = interface.HW_MTU if interface else None
if nh_mtu < path_mtu or (ph_mtu and ph_mtu < path_mtu):
path_mtu = min(nh_mtu, ph_mtu)
clamped_mtu = RNS.Link.signalling_bytes(path_mtu, mode)
new_raw = new_raw[:-RNS.Link.LINK_MTU_SIZE] + clamped_mtu
```
**Impact:** When both endpoints connect via TCP (HW_MTU=8192) through a V3 boundary node (HW_MTU=1064):
1. Sender's `LINKREQUEST` signals 8192-byte link MTU.
2. V3 forwards the request **unchanged** to the receiver.
3. Receiver confirms 8192-byte MTU → resource segments sized at ~7500 bytes (6 parts for a 46 KB file).
4. V3's HDLC deframer buffer (`rxbuf[1064]`) **silently truncates** oversized segments.
5. Only 4 of 6 truncated segments partially survive → receiver times out waiting for remaining parts → permanent stall at ~70%.
**Symptom:** LXMF resource transfers through the V3 boundary node stall permanently at ~70% progress. The sender keeps retrying but never completes.
### Bug 8b — `TcpInterface` does not declare `FIXED_MTU`
**File:** `TcpInterface.h`, constructor
**Code (before fix):**
```cpp
_HW_MTU = TCP_IF_HW_MTU; // 1064
// _FIXED_MTU defaults to false
```
**Impact:** Even if Transport had MTU clamping code, it would skip clamping for this interface because `FIXED_MTU()` returns `false`. The interface's `HW_MTU` value is not treated as authoritative.
### Bug 8c — HDLC deframer silently truncates oversized frames
**File:** `TcpInterface.h`, `_hdlc_deframe()`
**Code (before fix):**
```cpp
if (c.rxlen < TCP_IF_HW_MTU) {
c.rxbuf[c.rxlen++] = byte;
}
// Else: byte silently discarded, truncated frame delivered as if complete
```
**Impact:** When a client sends a frame larger than `TCP_IF_HW_MTU` (1064 bytes), the deframer silently drops bytes beyond the buffer limit and delivers the truncated frame to Transport as if it were complete. This corrupts resource data segments and hashmap updates, causing the resource transfer protocol to stall.
### Diagnosis
**Serial log evidence (pre-fix):**
- `LINK-XPORT: FWD` entries show 5 `RESOURCE_DAT` (ctx=1) segments forwarded, each silently truncated to 1064 bytes (from ~7500).
- After the 5th segment, no more `LINK-XPORT` entries — the receiver's `RESOURCE_HMU` (hashmap update, ctx=4) response is also truncated/corrupted and never processed.
- Receiver log: `"Timed out waiting for 4 parts, requesting retry"` — retry also stalls.
**Sender log evidence (pre-fix):**
```
Signalling link MTU of 8.19 KB for link
Destination confirmed link MTU of 8.19 KB ← should have been clamped to 1064
The transfer of <LXMessage ...> is in progress (70.0%) ← stuck forever
```
### Fix
#### 8a — MTU clamping in `Transport.cpp` (3 locations)
Added MTU clamping logic to all three `LINKREQUEST` forwarding paths. When the path MTU in the link request exceeds `min(prev-hop HW_MTU, next-hop HW_MTU)`, the signalling bytes are rewritten using `Link::signalling_bytes()`. If the outbound interface has no MTU or doesn't support MTU configuration, the signalling bytes are stripped entirely.
```cpp
uint16_t path_mtu = Link::mtu_from_lr_packet(packet);
if (path_mtu > 0) {
uint16_t ph_mtu = packet.receiving_interface().HW_MTU();
uint16_t nh_mtu = outbound_interface.HW_MTU();
if (nh_mtu == 0) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (!outbound_interface.AUTOCONFIGURE_MTU() && !outbound_interface.FIXED_MTU()) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (nh_mtu < path_mtu || (ph_mtu > 0 && ph_mtu < path_mtu)) {
uint16_t clamped = std::min(nh_mtu, (ph_mtu > 0) ? ph_mtu : nh_mtu);
auto mode = Link::mode_from_lr_packet(packet);
Bytes clamped_mtu_bytes = Link::signalling_bytes(clamped, mode);
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE) + clamped_mtu_bytes;
}
}
```
#### 8b — `FIXED_MTU` in `TcpInterface.h`
Set `_FIXED_MTU = true` in the constructor so Transport uses the interface's `HW_MTU` (1064) for clamping decisions.
#### 8c — Truncation detection in `TcpInterface.h`
Added a `truncated` flag to `TcpClient`. Frames exceeding `TCP_IF_HW_MTU` are now **dropped with a diagnostic log** instead of silently truncated:
```
[TcpIF] DROPPED oversized frame from client 1 (>1064 bytes, buffered 1064)
```
### Verification
**Sender log (post-fix):**
```
Signalling link MTU of 8.19 KB for link
Destination confirmed link MTU of 1.06 KB ← clamped!
*** DELIVERY RESULT: DELIVERED (state=8) elapsed=2.8s ***
```
**V3 serial log (post-fix):**
```
MTU CLAMP: path=8192 ph=1064 nh=1064 -> clamped=1064
```
**File integrity:** SHA-256 of received `test.pdf` matches original (`9bcb7b21d2bc7bbf...`).
### Test Reproduction
```bash
cd test-harnesses/RNodeTHV4
bash run_test.sh
```
Sends `test.pdf` (46.1 KB) as an LXMF attachment through the V3 boundary node. Pre-fix: stalls at 70%. Post-fix: delivers in ~3 seconds.

View File

@@ -711,6 +711,9 @@ void setup() {
local_tcp_rns_interface = local_tcp_interface_ptr;
local_tcp_rns_interface.mode(RNS::Type::Interface::MODE_GATEWAY);
RNS::Transport::register_interface(local_tcp_rns_interface);
// Register as local client interface so Transport forwards
// announces, link packets, and proofs to TCP clients
RNS::Transport::register_local_client_interface(local_tcp_rns_interface);
{
char _bm_msg[128];

View File

@@ -57,6 +57,7 @@ struct TcpClient {
// HDLC deframe state
bool in_frame;
bool escape;
bool truncated;
uint8_t rxbuf[TCP_IF_HW_MTU];
uint16_t rxlen;
};
@@ -84,6 +85,9 @@ public:
_IN = true;
_OUT = true;
_HW_MTU = TCP_IF_HW_MTU;
// v1.0.12: Tell Transport this interface has a known fixed MTU,
// enabling link MTU clamping when forwarding LINKREQUEST packets.
_FIXED_MTU = true;
// TCP links are effectively 10 Mbps+. Setting a realistic
// bitrate lets Transport prefer TCP paths over LoRa when
// both exist for the same destination.
@@ -100,6 +104,7 @@ public:
_clients[i].active = false;
_clients[i].in_frame = false;
_clients[i].escape = false;
_clients[i].truncated = false;
_clients[i].rxlen = 0;
_clients[i].last_activity = 0;
}
@@ -189,6 +194,7 @@ public:
}
}
}
}
// Process incoming data from all active clients
@@ -245,8 +251,14 @@ protected:
}
frame_buf[flen++] = HDLC_FLAG;
// Send to all connected clients (non-blocking: no flush)
// Send to all connected clients EXCEPT the one that sent this packet.
// v1.0.10: Echo prevention — if this send_outgoing was triggered by
// Transport forwarding a packet received from client N, skip client N
// to prevent echo-back that floods TCP buffers and stalls resource transfers.
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
if (i == _last_rx_client_idx) {
continue; // Don't echo back to sender
}
if (_clients[i].active && _clients[i].client.connected()) {
size_t written = _clients[i].client.write(frame_buf, flen);
if (written == 0) {
@@ -292,6 +304,7 @@ private:
c.active = false;
c.in_frame = false;
c.escape = false;
c.truncated = false;
c.rxlen = 0;
_num_clients--;
@@ -307,13 +320,29 @@ private:
if (byte == HDLC_FLAG) {
if (c.in_frame && c.rxlen > 0) {
// End of frame — deliver to RNS
RNS::Bytes data(c.rxbuf, c.rxlen);
handle_incoming(data);
c.rxlen = 0;
// v1.0.12: If the frame exceeded the buffer, drop it entirely
// instead of delivering a truncated/corrupt packet to Transport.
if (c.truncated) {
Serial.printf("[TcpIF] DROPPED oversized frame from client %d (>%d bytes, buffered %u)\r\n",
idx, TCP_IF_HW_MTU, c.rxlen);
c.truncated = false;
c.rxlen = 0;
} else {
// End of frame — deliver to RNS
// v1.0.10: Set _last_rx_client_idx so send_outgoing() can
// skip echoing this packet back to the client that sent it.
// The entire call chain (handle_incoming → Transport::inbound
// → transmit → send_outgoing) is synchronous, so this is safe.
RNS::Bytes data(c.rxbuf, c.rxlen);
_last_rx_client_idx = idx;
handle_incoming(data);
_last_rx_client_idx = -1;
c.rxlen = 0;
}
}
c.in_frame = true;
c.escape = false;
c.truncated = false;
c.rxlen = 0;
} else if (c.in_frame) {
if (c.escape) {
@@ -321,12 +350,16 @@ private:
c.escape = false;
if (c.rxlen < TCP_IF_HW_MTU) {
c.rxbuf[c.rxlen++] = byte;
} else {
c.truncated = true;
}
} else if (byte == HDLC_ESC) {
c.escape = true;
} else {
if (c.rxlen < TCP_IF_HW_MTU) {
c.rxbuf[c.rxlen++] = byte;
} else {
c.truncated = true;
}
}
}
@@ -355,6 +388,7 @@ private:
_clients[i].active = true;
_clients[i].in_frame = false;
_clients[i].escape = false;
_clients[i].truncated = false;
_clients[i].rxlen = 0;
_clients[i].last_activity = millis();
_num_clients++;
@@ -410,6 +444,7 @@ private:
_clients[0].active = true;
_clients[0].in_frame = false;
_clients[0].escape = false;
_clients[0].truncated = false;
_clients[0].rxlen = 0;
_clients[0].last_activity = millis();
_num_clients = 1;
@@ -446,6 +481,7 @@ private:
IPAddress _resolved_ip;
uint16_t _consecutive_failures;
bool _started;
int _last_rx_client_idx = -1; // v1.0.10: echo prevention — tracks which client is currently delivering an inbound frame
};
#endif // BOUNDARY_MODE

View File

@@ -1730,6 +1730,34 @@ static bool is_backbone_interface(const Interface& iface) {
TRACE("Transport::inbound: Packet is next-hop LINKREQUEST");
double now = OS::time();
double proof_timeout = now + Type::Link::ESTABLISHMENT_TIMEOUT_PER_HOP * std::max((uint8_t)1, remaining_hops);
// === MTU Clamping (v1.0.12) ===
// When forwarding a LINKREQUEST through this transport node,
// clamp the link MTU signalling to min(prev-hop, next-hop)
// interface HW_MTU. Without this, endpoints negotiate a
// segment size that exceeds this node's buffer capacity,
// causing silent truncation and resource transfer stalls.
uint16_t path_mtu = Link::mtu_from_lr_packet(packet);
if (path_mtu > 0) {
uint16_t ph_mtu = packet.receiving_interface().HW_MTU();
uint16_t nh_mtu = outbound_interface.HW_MTU();
if (nh_mtu == 0) {
DEBUG("MTU CLAMP: No next-hop HW MTU, stripping link MTU signalling");
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (!outbound_interface.AUTOCONFIGURE_MTU() && !outbound_interface.FIXED_MTU()) {
DEBUG("MTU CLAMP: Outbound interface doesn't support MTU config, stripping link MTU signalling");
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else {
if (nh_mtu < path_mtu || (ph_mtu > 0 && ph_mtu < path_mtu)) {
uint16_t clamped = std::min(nh_mtu, (ph_mtu > 0) ? ph_mtu : nh_mtu);
RNS::Type::Link::link_mode mode = Link::mode_from_lr_packet(packet);
Bytes clamped_mtu_bytes = Link::signalling_bytes(clamped, mode);
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE) + clamped_mtu_bytes;
DEBUGF("MTU CLAMP: path=%u ph=%u nh=%u -> clamped=%u", path_mtu, ph_mtu, nh_mtu, clamped);
}
}
}
LinkEntry link_entry(
now,
next_hop,
@@ -1848,6 +1876,25 @@ static bool is_backbone_interface(const Interface& iface) {
double now = OS::time();
double proof_timeout = now + Type::Link::ESTABLISHMENT_TIMEOUT_PER_HOP
* std::max((uint8_t)1, remaining_hops);
// === MTU Clamping (v1.0.12) ===
uint16_t path_mtu = Link::mtu_from_lr_packet(packet);
if (path_mtu > 0) {
uint16_t ph_mtu = packet.receiving_interface().HW_MTU();
uint16_t nh_mtu = outbound_interface.HW_MTU();
if (nh_mtu == 0) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (!outbound_interface.AUTOCONFIGURE_MTU() && !outbound_interface.FIXED_MTU()) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (nh_mtu < path_mtu || (ph_mtu > 0 && ph_mtu < path_mtu)) {
uint16_t clamped = std::min(nh_mtu, (ph_mtu > 0) ? ph_mtu : nh_mtu);
RNS::Type::Link::link_mode mode = Link::mode_from_lr_packet(packet);
Bytes clamped_mtu_bytes = Link::signalling_bytes(clamped, mode);
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE) + clamped_mtu_bytes;
DEBUGF("MTU CLAMP: local->backbone path=%u ph=%u nh=%u -> %u", path_mtu, ph_mtu, nh_mtu, clamped);
}
}
LinkEntry link_entry(
now, next_hop, outbound_interface, remaining_hops,
packet.receiving_interface(), packet.hops(),
@@ -1913,6 +1960,25 @@ static bool is_backbone_interface(const Interface& iface) {
double now = OS::time();
double proof_timeout = now + Type::Link::ESTABLISHMENT_TIMEOUT_PER_HOP
* std::max((uint8_t)1, remaining_hops);
// === MTU Clamping (v1.0.12) ===
uint16_t path_mtu = Link::mtu_from_lr_packet(packet);
if (path_mtu > 0) {
uint16_t ph_mtu = packet.receiving_interface().HW_MTU();
uint16_t nh_mtu = outbound_interface.HW_MTU();
if (nh_mtu == 0) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (!outbound_interface.AUTOCONFIGURE_MTU() && !outbound_interface.FIXED_MTU()) {
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE);
} else if (nh_mtu < path_mtu || (ph_mtu > 0 && ph_mtu < path_mtu)) {
uint16_t clamped = std::min(nh_mtu, (ph_mtu > 0) ? ph_mtu : nh_mtu);
RNS::Type::Link::link_mode mode = Link::mode_from_lr_packet(packet);
Bytes clamped_mtu_bytes = Link::signalling_bytes(clamped, mode);
new_raw = new_raw.left(new_raw.size() - Type::Link::LINK_MTU_SIZE) + clamped_mtu_bytes;
DEBUGF("MTU CLAMP: backbone->local path=%u ph=%u nh=%u -> %u", path_mtu, ph_mtu, nh_mtu, clamped);
}
}
LinkEntry link_entry(
now, next_hop, outbound_interface, remaining_hops,
packet.receiving_interface(), packet.hops(),

View File

@@ -312,6 +312,7 @@ namespace RNS {
static void handle_tunnel(const Bytes& tunnel_id, const Interface& interface);
static void register_interface(Interface& interface);
static void deregister_interface(const Interface& interface);
static void register_local_client_interface(const Interface& interface) { _local_client_interfaces.insert(std::cref(interface)); }
inline static const std::map<Bytes, Interface&> get_interfaces() { return _interfaces; }
static void register_destination(Destination& destination);
static void deregister_destination(const Destination& destination);