Skip to content

feat(meshcore): show hop count + relay route below received messages (#3742) — Phase 1#3750

Merged
Yeraze merged 2 commits into
mainfrom
feat/3742-meshcore-hop-route
Jun 25, 2026
Merged

feat(meshcore): show hop count + relay route below received messages (#3742) — Phase 1#3750
Yeraze merged 2 commits into
mainfrom
feat/3742-meshcore-hop-route

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Phase 1 of #3742 (the hop-count + routing-path display the repo owner scoped in the issue thread; the reporter confirmed Phase 1 as the starting point).

What

MeshCore already decodes a received message's hop count (path_len) and relay-hash route (path_hops) for the auto-ack {ROUTE}/{HOPS} templates, but that data was never persisted or shown. Now each received channel/DM message displays its route, like the native MeshCore app.

Changes

  • Schema/migration: meshcore_messages gains hopCount (int) + routePath (text), nullable, on all three backends. Migration 105 (idempotent). Existing rows and room messages (no path on the wire) stay NULL.
  • Manager (meshcoreManager.ts): the contact_message and channel_message handlers now compute hop/route before building the message and store them on it; addMessage persists them, and the boot DB-load maps them back.
  • Frontend: MeshCoreMessageStream renders a route line under received messages — 📍 direct for 0 hops, otherwise 🛰 N hops · a3 → 7f → 02. New MeshCoreMessage type fields; messages pass through the API/socket raw so no mapping churn.

Deferred

Phase 2 (scope display) — resolving the message's scope hash against the user's known scope list — is a follow-on per the reporter; this PR lays the per-message display slot it can reuse.

Tests

Migration registry count/last-name bumped to 105; a meshcore-repo insert+read round-trip asserts hopCount/routePath persist (incl. 0 hops → "direct" and NULL). Full suite 7549 passed, 0 failures; server tsc + build clean.

🤖 Generated with Claude Code

…3742)

Phase 1 of #3742. The hop count and relay-hash path are already decoded in
meshcoreManager for the auto-ack {ROUTE}/{HOPS} templates, but weren't
persisted or shown. Now:

- meshcore_messages gains hopCount + routePath columns (all 3 backends,
  migration 105, idempotent). Existing rows / room messages stay NULL.
- The contact_message and channel_message handlers compute hop/route before
  building the message and store them; addMessage persists them and the boot
  DB-load maps them back.
- MeshCoreMessageStream renders a route line under received messages:
  "📍 direct" for 0 hops, else "🛰 N hops · a3 → 7f → 02".

Phase 2 (resolving the scope hash to a known scope name) is deferred per the
reporter. Tests: migration count/last-name, meshcore insert+read round-trip of
hopCount/routePath. Full suite 7549 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): show hop count + relay route below received messages

Tasks:

  • Read changed files and analyze code
  • Review schema/migration changes
  • Review backend manager changes
  • Review repository changes
  • Review frontend changes
  • Review test coverage
  • Post final review

Overall Assessment

This is a clean, well-scoped implementation. The changes follow the codebase's established patterns for schema additions, the migration is properly idempotent on all three backends, and the frontend rendering is straightforward. A few items worth addressing before merge:


Issues / Suggestions

1. ID collision risk in contact_message / channel_message handlers (Medium)

src/server/meshcoreManager.ts lines ~994–995 and ~1024–1025:

id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,

This ID generation pattern predates this PR and isn't introduced here, but the new fields make it worth flagging — if two messages arrive within the same millisecond (possible on a busy channel), the Math.random() portion provides 7 base-36 characters of entropy (~1 in 78 billion chance of collision per pair). This is low risk but worth noting as a future improvement (e.g. using crypto.randomUUID()).

2. contact_message falls back to senderContact?.outPath for routePath — semantics mismatch (Minor)

src/server/meshcoreManager.ts lines ~991–993:

const hopCount = decodePathLenHopCount(data.path_len);
const senderContact = this.resolveContactByPrefix(data.pubkey_prefix);
const route = formatPathHops(data.path_hops) || senderContact?.outPath || null;

senderContact.outPath is the outbound path from this node to the sender — it is not the path the received message actually traveled. Using it as a fallback for an inbound message's relay route could display misleading hop information to the user (e.g. "🛰 3 hops · a3 → 7f" when the message actually arrived directly). The PR description acknowledges the fallback but the UI doesn't indicate that this particular route is an estimate vs. observed.

Consider either:

  • Omitting the outPath fallback so the route is only shown when an actual path_hops was observed
  • Or adding a visual indicator (e.g. italics / ~ prefix, or a different tooltip) when routePath came from the outPath fallback vs. live LogRxData

3. decodePathLenHopCount returns 0 for the sentinel 0xFF — but the DB stores 0 as "direct" (Minor)

src/server/meshcoreManager.ts lines ~63–65:

if (pathLen === 0xff) return 0;
return pathLen & 0x3f;

The 0xFF sentinel means "path length unknown" in MeshCore, but this function converts it to 0, which is then displayed as 📍 direct. The UI hopCount === 0 check at MeshCoreMessageStream.tsx:297 will therefore show "direct" for both true direct messages and messages where path length is unknowable. A distinct return value (e.g. null) for 0xFF would avoid this confusion. This also predates the PR, but with the new UI display slot, the distinction is now user-visible.

src/server/meshcoreManager.ts lines ~63–66:

function decodePathLenHopCount(pathLen: unknown): number | null {
  if (pathLen === undefined || pathLen === null) return null;
  if (pathLen === 0xff) return 0;   // ← should this also return null?
  return pathLen & 0x3f;
}

4. Missing null check before the route display for hopCount === 0 (Very Minor)

src/components/MeshCore/MeshCoreMessageStream.tsx line ~299:

{m.hopCount === 0
  ? t('meshcore.route_direct', '📍 direct')
  : `🛰 ${m.hopCount} ${m.hopCount === 1 ? t('meshcore.hop', 'hop') : t('meshcore.hops', 'hops')}`}
{m.routePath ? ` · ${m.routePath.split(',').filter(Boolean).join(' → ')}` : ''}

When m.hopCount is 0 (direct), m.routePath being non-null would produce 📍 direct · a3 → 7f — slightly inconsistent. In practice a direct message shouldn't have a path, but it's worth a {m.hopCount !== 0 && m.routePath ? ...} guard.

5. Test for NULL existing rows not covered (Very Minor)

The new test at meshcore.test.ts:228 covers the round-trip for hopCount/routePath but doesn't explicitly verify that pre-migration rows with NULL columns deserialize correctly at the TypeScript layer (i.e., that dbMsg.hopCount ?? null gives null rather than undefined). This is implicitly covered by the existing insertMessage stamps sourceId test which leaves those fields absent, but an explicit assertion for the null case would tighten the contract.


Positive Highlights

  • Migration idempotency is well-handled: SQLite catches duplicate column, Postgres uses ADD COLUMN IF NOT EXISTS, MySQL checks information_schema. This is the right pattern.
  • Schema parity across all three backends (SQLite INTEGER, Postgres INTEGER, MySQL INT NULL) looks correct.
  • Boot DB-load mapping (hopCount: dbMsg.hopCount ?? null) correctly handles pre-migration NULL rows.
  • channel_message correctly has no outPath fallback for routePath (per the comment — no sender pubkey on the wire), which avoids the semantic issue in feat: traceroute highlighting and UI improvements #2 for that message type.
  • CSS is minimal and appropriate — monospace font, subdued color, small size matches how Meshtastic shows relay info.
  • i18n keys are properly added (meshcore.route_direct, meshcore.hop, meshcore.hops, meshcore.route_tooltip), which is good practice.
  • The heardBy expansion state (expandedHeardBy) and the new route line are kept as separate concerns — no entanglement.

Summary

The implementation is solid. Items #2 and #3 are the most important for user-facing correctness: showing outPath as if it were the received route, and treating 0xFF as 0 hops (direct), could both produce subtly wrong UI. The rest are minor. Happy to see Phase 2 (scope display) build on this clean foundation.

@Yeraze

Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review — triaged all 5:

#2 (outPath fallback semantics) — fixed. You're right that outPath is the outbound path to the sender, not the inbound route, so it shouldn't appear as the received route. I decoupled the two uses: the stored/displayed routePath now uses only the observed formatPathHops(data.path_hops) (no fallback), while the {ROUTE} auto-ack template keeps its existing outPath fallback (renamed ackRoute) so acks can still cite a route when no LogRxData surfaced. Comment added explaining the split.

#3 (0xFF → 0 "direct") — working as intended, declining. In this codebase 0xFF is documented as "sent direct (no flood)", not "unknown" — see the decodePathLenHopCount doc comment (predates this PR) and it feeds the {HOPS} auto-ack template. Mapping it to 0/"direct" is the established, correct semantics here; returning null would change auto-ack behavior and mislabel genuine direct sends. Left as-is.

#4 (route shown alongside "direct") — fixed. Added the m.hopCount !== 0 && m.routePath guard so a 0-hop message never renders a path suffix. (With #2's fix a direct message won't carry a path anyway, but the guard is correct defensively.)

#5 (NULL-row deserialization) — fixed. Added an explicit test inserting a message with the fields omitted and asserting hopCount/routePath read back as null (not undefined), tightening the pre-migration-row contract.

#1 (ID Date.now()+Math.random() collision) — out of scope, declining. Pre-existing ID generation unrelated to this PR's display feature; ~1-in-78-billion per same-ms pair. Worth a separate crypto.randomUUID() cleanup but not bundling it here.

Full suite re-run + tsc + build clean.

… null test

Review feedback on PR #3750:

- Display route no longer falls back to the sender contact's outPath. outPath
  is the OUTBOUND path to the sender, not the inbound route the message took,
  so showing it as the received route is misleading. The stored/displayed
  routePath now uses only the observed LogRxData path_hops; the {ROUTE} auto-ack
  template keeps its outPath fallback (renamed ackRoute) so acks still cite a
  route when no LogRxData surfaced.
- Stream guards the route-path suffix with hopCount !== 0 so a 0-hop "direct"
  message never renders a relay chain.
- Added a repo test asserting an inserted message with hopCount/routePath
  omitted reads back as null (not undefined), tightening the pre-migration-row
  contract.

(0xFF→0 "direct" left as-is: documented codebase semantics — 0xFF means "sent
direct (no flood)" and feeds the {HOPS} template; not "unknown".)

Full suite 7550 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
@Yeraze

Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Pushed 5399224: #2 (route uses observed path_hops only; auto-ack keeps its outPath fallback), #4 (hopCount!==0 guard), #5 (null-row test). #1 and #3 declined with rationale above. Full suite 7550 pass.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): show hop count + relay route below received messages

Tasks:

  • Read changed files and analyze code
  • Review schema/migration changes
  • Review backend manager changes
  • Review repository and frontend changes
  • Review test coverage
  • Post final review

Overall Assessment

The follow-up fixes address all three accepted items (#2, #4, #5) cleanly. The code is in good shape. Only two minor observations remain:


Post-Fix Verification

#2 (outPath fallback removed) — confirmed correct.
src/server/meshcoreManager.ts:993contact_message now stores only formatPathHops(data.path_hops) as observedRoute, with no fallback to outPath. The ackRoute variable (line 996) keeps the fallback for auto-ack templates, which is the right separation. The explanatory comment is clear and accurate.

#4 (direct message route guard) — confirmed correct.
src/components/MeshCore/MeshCoreMessageStream.tsx:300 — the guard is now m.hopCount !== 0 && m.routePath, preventing a path suffix from appearing alongside "📍 direct".

#5 (NULL pre-migration row test) — confirmed correct.
src/db/repositories/meshcore.test.ts:246–258 — the new test inserts a message without hopCount/routePath and asserts both read back as null (not undefined). This correctly tightens the pre-migration-row contract.


Minor Observations (Non-blocking)

A. routePath VARCHAR length in MySQL is unbounded (TEXT) — inconsistent with schema convention

src/db/schema/meshcoreMessages.ts:84:

routePath: myText('routePath'),

src/server/migrations/105_add_meshcore_message_route.ts:60:

['routePath', 'routePath TEXT NULL']

The rest of the MySQL schema uses myVarchar with explicit lengths for bounded strings and myText only for long content (e.g. text for message body). routePath stores path_hops formatted as "a3,7f,02" — effectively a handful of hex tokens. A VARCHAR(255) would be safer and more consistent with the schema style (MeshCore hop chains are bounded by the protocol's max hop count). This is cosmetic and doesn't affect correctness.

B. useMeshCore.ts type for MeshCoreMessage — new fields not yet in MeshCoreNode JSDoc

src/components/MeshCore/hooks/useMeshCore.ts:89–91: hopCount and routePath are on MeshCoreMessage (the hook-level type), but the JSDoc on those fields (@property) is absent — the fields are typed correctly but have no inline doc. The existing heardBy field at line 87 also lacks a doc comment, so this is pre-existing style; no action required.


Positive Highlights

  • Migration 105 idempotency on all three backends is correct and consistent with the project pattern (SQLite try/catch for duplicate column, PG ADD COLUMN IF NOT EXISTS, MySQL information_schema check).
  • Schema parity across all three backends is confirmed: SQLite INTEGER/TEXT, Postgres INTEGER/TEXT, MySQL INT NULL/TEXT NULL.
  • Boot DB-load mapping (dbMsg.hopCount ?? null) correctly handles pre-migration NULL rows across all three backends.
  • The contact_message vs channel_message route comment split is well-documented and accurate.
  • The CSS (.mc-message-route) is minimal: monospace, subdued color, small font — matches how Meshtastic native shows relay info.
  • i18n keys (meshcore.route_direct, meshcore.hop, meshcore.hops, meshcore.route_tooltip) are properly added; the tooltip text is useful.
  • Test count in migrations.test.ts updated to 105 with the correct last-migration name assertion.

Summary

The three accepted fixes (#2, #4, #5) are all correct. The two declined items (#1, #3) have clear rationale and are reasonable calls. The only outstanding observations are cosmetic (TEXT vs VARCHAR(255) in MySQL for the route path, and missing JSDoc on the new hook fields). This PR is ready to merge.

View job

@Yeraze Yeraze merged commit b201323 into main Jun 25, 2026
20 checks passed
@Yeraze Yeraze deleted the feat/3742-meshcore-hop-route branch June 25, 2026 17:05
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.

1 participant