Skip to content

fix(meshcore): preserve real Last Heard across reconnect (#3645)#3650

Merged
Yeraze merged 1 commit into
mainfrom
fix/3645-meshcore-lastheard-reset
Jun 22, 2026
Merged

fix(meshcore): preserve real Last Heard across reconnect (#3645)#3650
Yeraze merged 1 commit into
mainfrom
fix/3645-meshcore-lastheard-reset

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #3645.

On MeshCore companion reconnect, every node's Last Heard reset to the reconnect time, discarding the real advert timestamps.

Root cause

refreshContacts() (which runs on connect/reconnect) set every contact's lastSeen to Date.now() and ignored the companion-reported advert time. persistContact writes lastHeard ← lastSeen, so meshcore_nodes.lastHeard became the reconnect wall-clock for every node — surfacing as "Last Heard: just now".

Fix

The companion reports each contact's last advert time (last_advert, epoch seconds) in the get_contacts response. refreshContacts() now:

  • derives lastSeen from last_advert (seconds → ms, with the same < 1e12 guard MeshCoreContactDetailPanel uses), falling back to Date.now() only when the device didn't report one;
  • populates the in-memory lastAdvert field (seconds) so the contact-detail panel's "Last Advert" is accurate too.

This satisfies both expected behaviors from the issue: Last Heard is preserved across reconnect (the device-reported value is stable, not the moving reconnect clock), and the actual advert timestamp is used for nodes the companion heard while MeshMonitor was disconnected.

Testing

  • New meshcoreManager.lastHeard.test.ts — advert-time used (not now), seconds→ms conversion, lastHeard persisted, fallback-to-now when absent, and stability across repeated reconnects.
  • Existing pathUpdate + contactPersistence suites still green.
  • tsc -p tsconfig.server.json clean.

🤖 Generated with Claude Code

On MeshCore companion reconnect, refreshContacts() set every contact's lastSeen
to Date.now() and discarded the companion-reported advert time, so persistContact
wrote the reconnect wall-clock into meshcore_nodes.lastHeard — surfacing as
"Last Heard: just now" for every node after a disconnect/reconnect, matching the
report.

The companion reports each contact's last advert time (`last_advert`, epoch
seconds) in the get_contacts response. Use it for lastSeen (seconds → ms,
mirroring MeshCoreContactDetailPanel's guard), falling back to Date.now() only
when the device didn't report one. Also populate the in-memory `lastAdvert`
field (seconds) so the contact-detail panel's "Last Advert" is accurate.

This both preserves Last Heard across reconnect (the device-reported value is
stable, not the moving reconnect clock) and uses the actual advert timestamp for
nodes the companion heard while MeshMonitor was disconnected.

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

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshcore): preserve real Last Heard across reconnect

Tasks:

  • Gather context and read changed files
  • Review meshcoreManager.ts changes
  • Review test file
  • Post comprehensive feedback

Summary

This is a clean, minimal fix for a real bug. The root cause analysis is accurate, the fix is correct, and the test coverage is solid. No blocking issues.


Code Review

src/server/meshcoreManager.ts (+13 / -1)

The fix (meshcoreManager.ts:1833–1847):

const advertSec = typeof c.last_advert === 'number' ? c.last_advert : 0;
const advertMs = advertSec > 0
  ? (advertSec < 1e12 ? advertSec * 1000 : advertSec)
  : undefined;
// ...
lastAdvert: advertSec > 0 ? advertSec : undefined,
lastSeen: advertMs ?? nowMs,

Correctness: The seconds→ms conversion and < 1e12 guard correctly mirrors the logic already used in MeshCoreContactDetailPanel.tsx:803–804. Consistent guard thresholds are important here — good call to keep them aligned.

Minor observation — nowMs hoisted outside the loop: const nowMs = Date.now() is computed once before the for loop rather than per-iteration. This is the right approach — all contacts in the same get_contacts response share the same fallback wall-clock, which avoids tiny skew between contacts when the fallback fires.

Minor observation — lastAdvert field was already in MeshCoreContact: The lastAdvert?: number field at meshcoreManager.ts:338 existed before this PR but was only ever populated by the contact_path_updated / advert-push path (meshcoreManager.ts:1031). refreshContacts() was silently discarding it. This PR closes that gap. Worth noting there's no data-loss risk — the DB upsertNode call (persistContact) ultimately writes lastHeard ← lastSeen, so fixing lastSeen is what matters for persistence.

No issues found. The change is exactly the right size.


src/server/meshcoreManager.lastHeard.test.ts (+105 / -0)

Coverage:

Scenario Covered
Advert time used (not now) ✅ line 48
Seconds→ms conversion verified ✅ line 62
lastAdvert field set in seconds ✅ line 65
lastHeard persisted to DB ✅ lines 68–71
Fallback to now when absent (last_advert: 0) ✅ line 74
Stability across repeated reconnects ✅ line 87

Test quality is high. vi.setSystemTime is used correctly to lock the fallback clock, and the stability test cleanly demonstrates that the fix is monotone (device value doesn't get clobbered by subsequent reconnect wall-clocks).

Minor observation — missing edge cases (non-blocking):

  • last_advert is undefined (field missing from device response entirely) vs. 0 — the current code handles this correctly (typeof c.last_advert === 'number' returns false for undefined, so advertSec = 0 → fallback), but a test for the undefined case would document that behavior explicitly.
  • The < 1e12 guard (already-in-ms branch) is not tested — if a future device ever reports milliseconds directly, the guard is in place but is currently untested.

Neither gap is a blocker, just potential additions if you want belt-and-suspenders coverage.

sourceId scoping: upsertNode is asserted with 'src-a' (line 69) — good, this confirms the per-source scoping invariant is exercised.


Verdict

Approve — Correct fix, clean implementation, well-tested. The two missing edge cases in tests are optional improvements, not blockers.

@Yeraze Yeraze merged commit f9aed11 into main Jun 22, 2026
19 checks passed
@Yeraze Yeraze deleted the fix/3645-meshcore-lastheard-reset branch June 22, 2026 15:14
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.

MeshCore: Last Heard timestamps reset to reconnect time on source reconnect

1 participant