fix(#495): send routed paths at the contact's width, not width 1

resolvePathSelection returned no width and, on the override branch, a byte
count in place of a hop count. preparePathForContactSend then called
setContactPath without a width, so encodePathLen packed mode bits 00 and a
2-byte route went out as twice as many 1-byte hops (path_len 0x06 for a
6-hop 2-byte route instead of 0x46). The radio routed on wrong 1-byte
prefixes, confirmed on Bandit's 2026-08-01 log and via CoreScope. Direct
and flood were immune because neither uses the path bytes.

PathSelection now carries hashWidth and a true hop count on every branch,
using the contact's pathHashWidth as the single width authority. Both
setContactPath call sites thread it. Also addresses the override timeout
inflation half of #299 (hopCount was a byte count feeding calculateTimeout).

Gemini review found two override/history width edge cases -> split to #494.

Refs #299

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix/279-send-width
Strycher 2 months ago
parent c2d49d9691
commit 1870dcbb5b

@ -3470,10 +3470,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Update any in-flight retries so they use the new path override
_retryService?.updatePendingContact(_contacts[index]);
// If setting a specific path (not flood, not auto), also sync with device
// If setting a specific path (not flood, not auto), also sync with device.
// pathLen from the override dialog is a BYTE count; convert to a true hop
// count at the contact's width so the wire path_len packs correctly (#279).
if (pathLen != null && pathLen >= 0 && pathBytes != null) {
final w = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth;
final hops = w > 0 ? pathBytes.length ~/ w : pathBytes.length;
appLogger.info('Sending path to device...', tag: 'Connector');
await setContactPath(contact, pathBytes, pathLen);
await setContactPath(contact, pathBytes, hops, hashWidth: w);
appLogger.info('Path sent to device', tag: 'Connector');
}
@ -3505,6 +3509,7 @@ class MeshCoreConnector extends ChangeNotifier {
contact,
Uint8List.fromList(resolved.pathBytes),
resolved.hopCount,
hashWidth: resolved.hashWidth,
);
}

@ -6,28 +6,52 @@ const int recentAttemptDiversityWindow = 2;
class PathSelection {
final List<int> pathBytes;
/// TRUE hop count (number of hops), or -1 for flood. NOT a byte count.
/// `pathBytes.length == hopCount * hashWidth` for a routed selection.
final int hopCount;
/// Bytes per hop hash (1..3). Defaults to 1 (legacy single-byte). Sent on the
/// wire packed with the hop count via `encodePathLen`. Without this, routed
/// sends went out at width 1 and a 2-byte route was read as twice as many
/// 1-byte hops, routing to the wrong nodes (#279).
final int hashWidth;
final bool useFlood;
const PathSelection({
required this.pathBytes,
required this.hopCount,
this.hashWidth = 1,
required this.useFlood,
});
}
/// Resolves the path to send to [contact], as a (bytes, true-hop-count, width)
/// triple. The contact's own `pathHashWidth` is the single width authority for
/// every non-flood branch, so an override, a device path, and a path-history
/// retry all encode at the width the route was actually captured at.
PathSelection resolvePathSelection(
Contact contact, {
PathSelection? selection,
bool forceFlood = false,
}) {
final width = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth;
// Hops for a byte array at [width]; integer division tolerates a malformed
// length rather than throwing.
int hopsFor(List<int> bytes) =>
width > 0 ? bytes.length ~/ width : bytes.length;
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
return PathSelection(
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
hopCount: contact.pathOverride!,
pathBytes: bytes,
hopCount: hopsFor(bytes),
hashWidth: width,
useFlood: false,
);
}
@ -36,10 +60,13 @@ PathSelection resolvePathSelection(
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
// Path-history retry: reuse the bytes, but derive hops/width from THIS
// contact so the wire encoding matches the contact's configured width.
if (selection != null && selection.pathBytes.isNotEmpty) {
return PathSelection(
pathBytes: selection.pathBytes,
hopCount: selection.hopCount,
hopCount: hopsFor(selection.pathBytes),
hashWidth: width,
useFlood: false,
);
}
@ -47,6 +74,7 @@ PathSelection resolvePathSelection(
return PathSelection(
pathBytes: contact.path,
hopCount: contact.pathLength,
hashWidth: width,
useFlood: false,
);
}

@ -0,0 +1,119 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/models/path_selection.dart';
Contact _contact({
required int pathLength,
required int pathHashWidth,
List<int> path = const [],
int? pathOverride,
List<int>? pathOverrideBytes,
}) {
return Contact(
publicKey: Uint8List(32),
name: 'R',
type: advTypeRepeater,
pathLength: pathLength,
pathHashWidth: pathHashWidth,
path: Uint8List.fromList(path),
pathOverride: pathOverride,
pathOverrideBytes: pathOverrideBytes == null
? null
: Uint8List.fromList(pathOverrideBytes),
lastSeen: DateTime.utc(2026),
);
}
void main() {
const pathLenOffset = 35; // 1 cmd + 32 pubKey + 1 type + 1 flags
group('resolvePathSelection width (#279)', () {
test('device path reports true hops + the captured width', () {
// Bandit 2026-08-01: a 6-hop 2-byte route (12 path bytes). It must NOT be
// reported as 12 hops nor sent at width 1.
final bytes = [
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
];
final r = resolvePathSelection(
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
);
expect(r.useFlood, isFalse);
expect(r.hopCount, 6);
expect(r.hashWidth, 2);
expect(r.pathBytes.length, 12);
});
test('end to end: the device path encodes as 0x46, not 0x06', () {
final bytes = [
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
];
final r = resolvePathSelection(
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
);
final frame = buildUpdateContactPathFrame(
Uint8List(32),
Uint8List.fromList(r.pathBytes),
r.hopCount,
hashWidth: r.hashWidth,
);
expect(frame[pathLenOffset], 0x46);
expect(pathHopCount(frame[pathLenOffset]), 6);
expect(pathHashSizeBytes(frame[pathLenOffset]), 2);
});
test('override: one 2-byte hop is 1 hop at width 2, not 2 hops', () {
// The dialog stores pathOverride as a BYTE count (2); the selection must
// still resolve to a single 2-byte hop.
final r = resolvePathSelection(
_contact(
pathLength: 1,
pathHashWidth: 2,
pathOverride: 2,
pathOverrideBytes: [0xC6, 0x5C],
),
);
expect(r.hopCount, 1);
expect(r.hashWidth, 2);
});
test('legacy 1-byte net is unchanged', () {
final r = resolvePathSelection(
_contact(pathLength: 3, pathHashWidth: 1, path: [0xAA, 0xBB, 0xCC]),
);
expect(r.hopCount, 3);
expect(r.hashWidth, 1);
});
test('flood override passes through as flood', () {
final r = resolvePathSelection(
_contact(pathLength: 0, pathHashWidth: 2, pathOverride: -1),
);
expect(r.useFlood, isTrue);
expect(r.hopCount, -1);
});
test('path-history retry derives width from the contact', () {
final retry = const PathSelection(
pathBytes: [0xC6, 0x5C, 0xA1, 0xB2],
hopCount: 99, // stale/ambiguous; must be recomputed
useFlood: false,
);
final r = resolvePathSelection(
_contact(
pathLength: 2,
pathHashWidth: 2,
path: [0x00, 0x00, 0x00, 0x00],
),
selection: retry,
);
expect(r.pathBytes.length, 4);
expect(r.hopCount, 2);
expect(r.hashWidth, 2);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.