Skip to content

fix(traceroute): prevent fictitious direct-connection segments for target-node traceroutes (#3622)#3635

Merged
Yeraze merged 3 commits into
mainfrom
claude/great-dijkstra-awe8pn
Jun 22, 2026
Merged

fix(traceroute): prevent fictitious direct-connection segments for target-node traceroutes (#3622)#3635
Yeraze merged 3 commits into
mainfrom
claude/great-dijkstra-awe8pn

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #3622 — two interacting bugs when MeshMonitor is connected to the target node (L) of an incoming traceroute from A:

  • Bug 1 (fictitious segment): The traceroute REQUEST (from=A, to=L, requestId=0) was being processed and saved with routeBack=[]. segmentsForTraceroute then built backPath=[A, L] — a fictitious direct-connection line on the map overlay, even when the actual path went through relay nodes.
  • Bug 2 (invisible traceroute): The prior fix for [BUG] Route segments on map: outgoing trace route response shown #1140 used a broad guard (if fromNum === localNode → return) that blocked saving the RESPONSE entirely, making the traceroute invisible to the user.

Three-part fix

meshtasticManager.ts — Replace broad from===localNode guard with requestId-based logic:

  • requestId=0 (REQUEST): skip entirely — firmware will respond, record comes when RESPONSE arrives
  • requestId≠0 from localNode (RESPONSE): save the DB record but skip route-segment creation (routeBack is not yet populated by relay nodes)

useTracerouteAnalysis.ts — Guard segmentsForTraceroute back-path loop:

  • Skip when both routeBack and snrBack are empty (unresolved return path, not a genuine direct hop)
  • Draw when snrBack has data (genuine single-hop with measured SNR)

utils/traceroute.tsx — Guard formatTracerouteRoute for history modal:

  • Return '(No return path data)' when route and snr both parse to empty arrays

Test plan

  • src/hooks/useTracerouteAnalysis.emptyBack.test.ts — 4 new tests: empty routeBack+snrBack → no segment; snrBack present → segment drawn; forward path unaffected; populated routeBack works
  • src/utils/traceroute.emptyBack.test.ts — 4 new tests: empty arrays → sentinel string; snr present → renders; null → existing behavior; multi-hop → renders
  • src/server/meshtasticManager.traceroute-hops.test.ts — updated packet fixtures to include requestId: 12345 (responses, not requests) so all 7 existing tests pass the new REQUEST-skip guard
  • Full Vitest suite passes (pre-existing mqttBrokerManager failures are unrelated to this change)

🤖 Generated with Claude Code

https://claude.ai/code/session_01UZwPi1Waokuoyv8pppjtCA


Generated by Claude Code

…rget-node traceroutes (#3622)

When MeshMonitor is connected to the TARGET node (L) of an incoming
traceroute from node A:

1. L's firmware receives the REQUEST (requestId=0) and immediately emits a
   RESPONSE (requestId≠0) before relay nodes populate routeBack.

2. OLD behaviour:
   - processTracerouteMessage processed the REQUEST, saving a record with
     wrong from/to orientation and routeBack='[]'. A broad guard then
     skipped the RESPONSE (fromNum === localNode), so users saw neither the
     correct traceroute NOR the return path — only a fictitious A→L direct
     line on the map overlay.
   - segmentsForTraceroute in useTracerouteAnalysis always built
     backPath=[responder, requester] even when routeBack AND snrBack were
     both empty, drawing the direct segment.

3. NEW behaviour (three-part fix):
   a. meshtasticManager.ts — processTracerouteMessage now inspects
      decoded.requestId: REQUEST packets (requestId=0) are skipped
      entirely; RESPONSE packets from localNode are saved to the DB but
      skip route-segment creation since routeBack is not yet populated.
   b. useTracerouteAnalysis.ts — segmentsForTraceroute guards the back-path
      loop: skipped when both routeBack AND snrBack are empty (unresolved
      return path). Preserved when snrBack has data (genuine direct hop).
   c. traceroute.tsx — formatTracerouteRoute returns '(No return path
      data)' when both route and snr parse to empty arrays, preventing the
      fictitious A→B line in TracerouteHistoryModal.

Tests: add useTracerouteAnalysis.emptyBack.test.ts (4 tests) and
traceroute.emptyBack.test.ts (4 tests) covering the new guards; update
meshtasticManager.traceroute-hops.test.ts to set requestId on RESPONSE
test packets so they pass the new REQUEST-skip guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZwPi1Waokuoyv8pppjtCA
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read changed files
  • Review meshtasticManager.ts changes
  • Review useTracerouteAnalysis.ts changes
  • Review traceroute.tsx changes
  • Review test files
  • Post final review

Overall Assessment

This is a well-structured, carefully reasoned fix for a subtle dual-bug. The root causes are correctly identified, the three-part fix addresses each layer independently, and the test coverage is solid. A few notes below.


meshtasticManager.ts (lines ~6750–7265)

Good:

  • The requestId-based guard (tracerouteRequestId === 0 → skip) is the correct discriminator. This aligns with the Meshtastic protocol where requestId=0 is the REQUEST frame.
  • The isLocalNodeResponse path correctly saves the DB record but skips route-segment creation — preserving traceroute visibility while preventing fictitious segments.
  • The comment at line 7259–7261 (// Skip for our own outgoing response) accurately documents the invariant guarding !isLocalNodeResponse.

Potential concern — insertTraceroute is called for isLocalNodeResponse without await:

// line 7191
databaseService.insertTraceroute(tracerouteRecord, this.sourceId ?? undefined);

This unawaited call exists in the pre-existing code and is not introduced by this PR, but it means an error from insertTraceroute on the local-node response path would be silently swallowed. Not a blocker, but worth noting as a pre-existing footgun.

Potential concern — isLocalNodeResponse relies on this.localNodeInfo:

If localNodeInfo is null (connection not yet fully initialized), isLocalNodeResponse evaluates to false, and the code falls through to segment creation with an empty routeBack. This is the same pre-existing race that issue #1140 tried to address. The new guard in segmentsForTraceroute (checking routeBack.length > 0 || snrBack.length > 0) provides a second line of defense at the analysis layer, so this is mitigated — but the server-side segment creation at line 7265 could still fire in that narrow window. Consider whether the useTracerouteAnalysis guard alone is sufficient or whether meshtasticManager should also guard on empty routeBack before calling insertRouteSegment.


useTracerouteAnalysis.tssegmentsForTraceroute (lines 150–165)

Good:

Edge case — routeBack=[] + snrBack=null:

parseNumArray(null) returns [], so if snrBack is null in the DB (e.g., older rows), the guard evaluates 0 > 0 || 0 > 0false → back path suppressed. This is the correct behavior for old rows without snrBack data, but it's worth verifying that snrBack is never stored as null when there is genuinely a direct RF link. Based on meshtasticManager.ts line 7182, snrBack: JSON.stringify(snrBack) always produces at least '[]' not null for new rows, so this should be fine in practice.


utils/traceroute.tsxformatTracerouteRoute (lines 121–129)

Good:

  • The early-exit guard (route=[] AND snr=[]) correctly targets the specific state that produced the fictitious UI label.
  • The sentinel string '(No return path data)' is distinct from '(No response received)' (null route) — callers can differentiate the two states if needed.

Minor — JSON.parse inside the guard duplicates work:

Lines 122–124 parse route and snr again, even though the main try block below (line 143) will parse them a third time. This is a readability/performance minor point; the data is small so it's not a real concern. An alternative would be to inline this check into the main parsing logic, but the current approach is clear.

Minor — snr null handling in guard:

const _s = snr ? JSON.parse(snr) : [];

If snr is 'null' (a literal string), JSON.parse('null') returns null, and Array.isArray(null) is false, so the guard falls through correctly. This is fine.


Test files

useTracerouteAnalysis.emptyBack.test.ts:

  • All 4 test cases cover the key behavioral branches: suppress empty-back, allow snrBack-present, forward path unaffected, populated routeBack works.
  • Test 3 correctly uses scopeToSelectedNode: false to see all segments in the global view — good discipline.

traceroute.emptyBack.test.ts:

  • All 4 cases cover the guard branches cleanly.
  • Test 4 uses 0xcccc0003 = 3221291011 — verified: 3221291011 = 0xC000_0003, not 0xCCCC0003. The actual hex is 3221291011 = 0xC000_0003. Let me double-check: 0xcccc0003 = 3435528195. The test comment says "one intermediate hop (0xcccc0003)" but the JSON literal is '[3221291011]'. 3221291011 = 0xBFFF_0003? Actually 3221291011 in hex is 0xC000_0003. This is a comment/value mismatch — the node constant C = 0xcccc0003 defined in the sibling test is 3435528195, not 3221291011. This does NOT affect test correctness (the guard test only checks that the result is NOT the sentinel string), but the comment // one intermediate hop (0xcccc0003) is misleading.

meshtasticManager.traceroute-hops.test.ts:

  • Adding requestId: 12345 to existing fixtures is the minimal correct change to let those tests bypass the new REQUEST-skip guard. Correct approach.

Summary

The fix is correct, well-scoped, and well-tested. The main items worth a follow-up look are:

  1. Pre-existing gap: isLocalNodeResponse can be false on an uninitialized connection, and the server-side insertRouteSegment block at line 7265 could still run with an empty routeBack. The useTracerouteAnalysis guard is a good second defense, but if the segment is inserted in DB (e.g., a 0-distance segment), it could affect future historical map renders. Consider whether isLocalNodeResponse should be replaced with a direct routeBack.length === 0 && snrBack.length === 0 guard in the server-side segment-creation block.

  2. Minor test comment mismatch: traceroute.emptyBack.test.ts line 77 comments // one intermediate hop (0xcccc0003) but the literal is 3221291011 which is not 0xcccc0003. Non-blocking.

…zed localNodeInfo race

Address reviewer feedback on PR #3635:

- meshtasticManager.ts: replace the sole `isLocalNodeResponse` guard on
  the insertRouteSegment block with a dual check: `!isLocalNodeResponse &&
  !isEmptyReturnPath`. The second condition (`routeBack.length === 0 &&
  snrBack.length === 0`) catches the same fictitious-segment scenario even
  when `localNodeInfo` is null (connection not yet fully initialized),
  closing a pre-existing race window that the prior fix left open.

- traceroute.emptyBack.test.ts: correct comment for the intermediate hop
  literal 3221291011 from `(0xcccc0003)` to `(0xc0010003)`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZwPi1Waokuoyv8pppjtCA
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read changed files
  • Review meshtasticManager.ts changes
  • Review useTracerouteAnalysis.ts changes
  • Review traceroute.tsx changes
  • Review test files
  • Post final review

Overall Assessment

This is a well-structured, carefully reasoned fix for a subtle dual-bug. The root causes are correctly identified, the three-part fix addresses each layer independently, and the test coverage is solid. A few notes below.


meshtasticManager.ts (lines ~6750–7266)

Good:

  • The requestId-based guard (tracerouteRequestId === 0 → skip) is the correct discriminator. This aligns with the Meshtastic protocol where requestId=0 is the REQUEST frame.
  • The isLocalNodeResponse path correctly saves the DB record but skips route-segment creation — preserving traceroute visibility while preventing fictitious segments.
  • The comment at lines 7259–7264 accurately documents both conditions used for defence-in-depth.
  • isEmptyReturnPath (line 7265) adds a second guard for the localNodeInfo == null race window, which correctly closes a pre-existing gap.

Potential issue — insertTraceroute called without await (line 7191):

databaseService.insertTraceroute(tracerouteRecord, this.sourceId ?? undefined);

This is pre-existing, but errors on the local-node response path are silently swallowed. If insertTraceroute throws, the record is lost with no log entry. Not introduced by this PR, but worth noting since this PR now routes more traffic through that unawaited path.

Observation — isEmptyReturnPath also guards against legitimate single-hop responses from remote nodes:

If a remote node (not the local node) sends a traceroute response that naturally has routeBack=[] and snrBack=[] (e.g., old firmware that doesn't populate these fields), the segment-creation block is also skipped. This is probably the intended behaviour (no data → no fictitious segment), but a debug log here would help distinguish it from the local-node case in production:

if (!isLocalNodeResponse && isEmptyReturnPath) {
  logger.debug('🗺️ Skipping segment creation — empty return path from remote node');
}

useTracerouteAnalysis.tssegmentsForTraceroute (lines 150–165)

Good:

Edge case — routeBack=[] + snrBack=null:

parseNumArray(null) returns [], so if snrBack is stored as null in the DB (older rows pre-dating this PR), the guard evaluates 0 > 0 || 0 > 0false → back path suppressed. This is the correct behaviour for old rows without snrBack data, but it's worth verifying that snrBack is never stored as null for genuine direct links. Based on line 7182, snrBack: JSON.stringify(snrBack) always produces at least '[]', not null, for new rows — so this is safe in practice.


utils/traceroute.tsxformatTracerouteRoute (lines 118–129)

Good:

  • The early-exit guard (route=[] AND snr=[]) correctly targets the specific state that produced the fictitious UI label.
  • The sentinel string '(No return path data)' is distinct from '(No response received)' — callers can differentiate the two states if needed.

Minor — JSON.parse inside the guard duplicates work:

The guard at lines 122–124 parses route and snr, and then the main try block at line 144 parses them again. The data is small so performance is not a real concern, but it does mean a malformed JSON string could throw in the guard, get silently caught, and then throw again inside the main try. The current logic is correct and clear; this is purely a readability note.


Test files

useTracerouteAnalysis.emptyBack.test.ts:

  • All 4 test cases cover the key behavioral branches: suppress empty-back, allow snrBack-present, forward path unaffected, populated routeBack works.
  • Test 3 correctly uses scopeToSelectedNode: false to see all segments in the global view.
  • Test 2 verifies SNR scaling (32 / 4 = 8) — good regression anchor.

traceroute.emptyBack.test.ts:

  • All 4 cases cleanly cover the guard branches.
  • Minor comment mismatch (non-blocking): Line 77 says // one intermediate hop (0xc0010003) but the literal is '[3221291011]'. 3221291011 in hex is 0xC000_0003, not 0xc001_0003. The test logic is correct regardless — it only checks that the result is NOT the sentinel string — but the comment is slightly misleading.

meshtasticManager.traceroute-hops.test.ts:

  • Adding requestId: 12345 to the shared base fixture is the minimal and correct change to let the 7 existing tests bypass the new REQUEST-skip guard.

Summary

The fix is correct, well-scoped, and well-tested. Two items worth a follow-up:

  1. Optional debug log in meshtasticManager.ts when isEmptyReturnPath triggers for a non-local node — helps distinguish the two silent-skip paths in production logs.
  2. Minor test comment (traceroute.emptyBack.test.ts line 77): 0xc001_0003 should be 0xC000_0003. Non-blocking.
    | Branch: claude/great-dijkstra-awe8pn

Add a debug log when the isEmptyReturnPath guard skips segment creation
for a non-local node (e.g. old firmware without snrBack, or an unresolved
return path). This distinguishes the remote-node case from the local-node
response case in production logs, which previously both silently skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZwPi1Waokuoyv8pppjtCA
@Yeraze Yeraze merged commit d84269b into main Jun 22, 2026
13 checks passed
@Yeraze Yeraze deleted the claude/great-dijkstra-awe8pn branch June 22, 2026 11:59
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read changed files
  • Review meshtasticManager.ts changes
  • Review useTracerouteAnalysis.ts changes
  • Review traceroute.tsx changes
  • Review test files
  • Post final review

Overall Assessment

This is a well-structured, carefully reasoned fix for a subtle dual-bug. The root causes are correctly identified, the three-part fix addresses each layer independently, and the test coverage is solid. A few notes below.


meshtasticManager.ts (lines ~6750–7266)

Good:

  • The requestId-based guard (tracerouteRequestId === 0 → skip) is the correct discriminator. This aligns with the Meshtastic protocol where requestId=0 is the REQUEST frame.
  • The isLocalNodeResponse path correctly saves the DB record but skips route-segment creation — preserving traceroute visibility while preventing fictitious segments.
  • The comment at lines 7259–7264 accurately documents both conditions used for defence-in-depth.
  • isEmptyReturnPath (line 7265) adds a second guard for the localNodeInfo == null race window, which correctly closes a pre-existing gap.

Potential issue — insertTraceroute called without await (line 7191):

databaseService.insertTraceroute(tracerouteRecord, this.sourceId ?? undefined);

This is pre-existing, but errors on the local-node response path are silently swallowed. If insertTraceroute throws, the record is lost with no log entry. Not introduced by this PR, but worth noting since this PR now routes more traffic through that unawaited path.

Observation — isEmptyReturnPath also guards against legitimate single-hop responses from remote nodes:

If a remote node (not the local node) sends a traceroute response that naturally has routeBack=[] and snrBack=[] (e.g., old firmware that doesn't populate these fields), the segment-creation block is also skipped. This is probably the intended behaviour (no data → no fictitious segment), but the debug log at line 7267 makes this visible in logs when it happens — good defensive practice.


useTracerouteAnalysis.tssegmentsForTraceroute (lines 150–165)

Good:

Edge case — routeBack=[] + snrBack=null (pre-existing DB rows):

parseNumArray(null) returns [], so if snrBack is stored as null in the DB (older rows pre-dating this PR), the guard evaluates 0 > 0 || 0 > 0false → back path suppressed. This is the correct behaviour for old rows without snrBack data. Based on line 7182, snrBack: JSON.stringify(snrBack) always produces at least '[]' (not null) for new rows — so this is safe in practice, but pre-existing rows with null snrBack could have their back-path suppressed after upgrade. Worth a comment if this matters for historical data.


utils/traceroute.tsxformatTracerouteRoute (lines 118–129)

Good:

  • The early-exit guard (route=[] AND snr=[]) correctly targets the specific state that produced the fictitious UI label.
  • The sentinel string '(No return path data)' is distinct from '(No response received)' (null route) — callers can differentiate the two states if needed.

Minor — JSON.parse inside the guard duplicates work:

Lines 122–124 parse route and snr for the guard check, and then the main try block at line 144 parses them again. The data is small so performance is not a concern, but a malformed JSON string would throw in the guard, get silently caught, then throw again inside the main try. The current logic is correct and clear; this is purely a readability note.


Test files

useTracerouteAnalysis.emptyBack.test.ts:

  • All 4 test cases cover the key behavioral branches: suppress empty-back, allow snrBack-present, forward path unaffected, populated routeBack works.
  • Test 3 correctly uses scopeToSelectedNode: false to see all segments in the global view.
  • Test 2 verifies SNR scaling (32 / 4 = 8) — good regression anchor.

traceroute.emptyBack.test.ts:

  • All 4 cases cleanly cover the guard branches.
  • Minor comment mismatch (non-blocking): Line 77 says // one intermediate hop (0xc0010003) but the literal is '[3221291011]'. 3221291011 in hex is 0xC000_0003, not 0xc001_0003. The test logic is correct regardless — it only checks that the result is NOT the sentinel string — but the comment is slightly misleading.

meshtasticManager.traceroute-hops.test.ts:

  • Adding requestId: 12345 to the shared base fixture is the minimal and correct change to let the 7 existing tests bypass the new REQUEST-skip guard.

Summary

The fix is correct, well-scoped, and well-tested. Two items worth a follow-up:

  1. Pre-existing unawaited insertTraceroute — errors on the local-node response path are silently swallowed. This is not introduced by this PR but is now exercised more frequently.
  2. Minor test comment (traceroute.emptyBack.test.ts line 77): 0xc001_0003 should be 0xC000_0003 (3221291011 = 0xC000_0003). Non-blocking.
    | Branch: claude/great-dijkstra-awe8pn

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] and again about incoming traceroutes

2 participants