fix(map): render an unobserved hash size as unknown, not as 1 byte - #1912
fix(map): render an unobserved hash size as unknown, not as 1 byte#1912TeTeHacko wants to merge 3 commits into
Conversation
`node.hash_size` is evidence: the server sets it only from adverts it could
read a size out of, so a node with no countable advert in the retention window
has no value at all. map.js turned that absence into `1` via `hash_size || 1`,
which claims a 1-byte configuration nobody observed — in three places: the
marker label, the popup's Hash Prefix row, and the Byte Size filter.
nodes.js already renders "Unknown" for the same field (node detail) and
analytics.js renders "?B", so the map was the odd one out: the same node reads
"Unknown" on its detail page and "C8 (1B)" on the map.
On analyzer.meshcore.cz 701 of 1007 nodes currently have no hash_size, so the
map's 1-byte bucket is mostly nodes that were never measured.
- roles.js: shared hashPrefixInfo() → {known, bytes, prefix}
- map.js: label keeps a 1-byte prefix (it has to draw something) but is marked
.hash-unconfirmed and says "hash size unknown" in its aria-label; the popup
says Unknown; the filter gets its own Unknown bucket instead of folding
unmeasured nodes into 1-byte
- style.css: dotted underline for the unconfirmed prefix — a shape cue, so it
survives forced-colors and color-vision differences
Tests: 10 new cases in test-frontend-helpers.js, including a guard that map.js
contains no bare `hash_size || 1`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review from the queue triage. Written 2026-08-30 against the tree at that time; posting now that the maintenance window in #1922 has opened. Verdict: approve. Best-argued PR in the queue. Shared Load-order dependency checked and safe: the helper is defined on |
|
Recycling this again, and the reason is my mistake rather than anything about your PR. Earlier today I approved the pending workflow run on this PR. That was the wrong order: approving an Closing and reopening now gets a fresh merge commit against current master, which is what the run should have been all along. No action needed from you, and apologies for the second round of noise. |
|
Recycling for a fresh run. The result you have is from a stale base and says nothing about your PR. That run was created on 2026-08-25 and I approved it earlier today, which was my mistake: approving an Closing and reopening to get a real one. My review above is unchanged: this is the best-argued PR in the queue. |
|
The fresh run failed too, so this one is real and not the stale base I described earlier. It is a one-line fix, and the behaviour of your PR is not wrong. What fails: That assertion is a source grep with a distance limit, not a behavioural test: assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');It requires var shortHash = hashInfo.prefix;
var unknownWidth = hashInfo.known ? '' : ' hash-unconfirmed'; // <- inserted here
// #1356 V3: glyph is the primary non-color status carrier, hash is the data,
// status color is a thin left-border (CSS class drives the hue).
var status = mbStatus || null;
var glyph = status ? (MB_GLYPHS[status] || MB_GLYPHS.unknown) : '';The glyph is still prepended — Simplest fix: move the The deeper problem is that #1356's assertion tests where two identifiers sit in the source rather than what the function produces, so it fails on any reordering. Worth fixing separately; I would rather not rewrite someone else's a11y test inside your PR. My review above otherwise stands: this is still the best-argued PR in the queue, and the regression guard you added (grepping map.js for a bare |
Continues #1771 by @ArcanConsulting. Both commits are theirs, authorship unchanged; I only rebased them onto current master. Opening it here rather than force-pushing to someone else's branch. ## Why the rebase was needed #1771 went CONFLICTING through no fault of its author: #1760 landed first and both PRs append a line to `test-all.sh` at the same spot. That was the entire conflict. ## What I changed One line, and it is the conflict resolution: `test-all.sh` now runs **both** test files rather than either. ``` node test-repeater-metric-scatter.js # from #1760 node test-top-routes-overlay.js # from this PR ``` Nothing else was touched. `public/map.js` and `test-issue-1329-map-controls-accordion-e2e.js` are byte-for-byte as the author wrote them. ## Verification on the rebased tree | | result | |---|---| | `test-top-routes-overlay.js` (this PR's own) | 20 passed, 0 failed | | `test-repeater-metric-scatter.js` (#1760's, must still pass) | 31 passed, 0 failed | | `test-frontend-helpers.js` | 627 passed, 0 failed | ## The one review point that still stands From my review on #1771, unchanged by the rebase and not something I fixed on the author's behalf: `test-top-routes-overlay.js` extracts the ranking core by `indexOf`-slicing `public/map.js` between the literals `const TOP_ROUTES_AXES` and `function clearTopRoutes`, then `new Function`s the result. There is a guard assertion for the rename case, which is thoughtful, but it still breaks on any reordering of map.js and it tests a string rather than the module. Two PRs in this same queue do it properly and are worth copying: #1821 exports `applyObserverFilter` through `_packetsTestAPI`, and #1912 puts `hashPrefixInfo` on `window`. Happy to take that as a follow-up rather than block the overlay on it. @ArcanConsulting — this is your work and the credit is yours. Say the word and I will close this and hand the rebase back, or push it to your branch instead if you would rather #1771 stayed the vehicle. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE --------- Co-authored-by: Arcan Consulting - Michael J. Arcan <github@arcan-it.de>
…ep test passes
test-issue-1356-map-a11y.js:133 asserts the glyph-before-hash ordering of
makeRepeaterLabelIcon with a source grep, not a behavioural check:
/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/
It requires the two identifiers to sit within 200 characters of each other in
public/map.js. The `unknownWidth` line this PR adds landed between them and
pushed the gap to 299, so the build failed while the rendering was untouched --
`visible = (glyph ? glyph + ' ' : '') + shortHash` never changed.
Moving `unknownWidth` below the glyph line, as suggested in review, is not
enough: it only brings the gap to 233, still 33 over the limit. Moving
`shortHash` itself below the glyph does pass, reads in the order the assertion
describes, and is safe -- nothing between the two uses it, its first use is
`ariaStatus`.
A comment at the site records the constraint so a later reorder does not
silently break the build again.
Verified by running the tests, not by reading the regex: the whole JS unit list
from .github/workflows/deploy.yml passes, 65/65, with
test-issue-1356-map-a11y.js at 40/40.
|
Pushed The assertion needs
Moving So I also left a comment at the site recording the 200-character constraint, so the next reorder does not rediscover this the hard way. Verified: the whole JS unit list from On the underlying problem — agreed, and thanks for not rewriting #1356's test inside this PR. An assertion that pins where two identifiers sit in the source will fail on any refactor that touches the neighbourhood, and it gives no signal about what the function produces. Happy to open a separate PR turning it into a check on the emitted label string if that is wanted; it seemed out of scope here. No apology needed for the recycling, by the way. Getting a CI result against current master is worth a couple of notifications. |
…ion itself The comment added in ffda450 spelled out `shortHash` and `MB_GLYPHS[` within 200 characters of each other -- exactly the pattern test-issue-1356-map-a11y.js:133 greps for. The assertion was therefore satisfied from inside the comment, independent of the code: moving `shortHash` back to its original position still reported green. That turned a check which had just caught a real defect into one that can never fail, which is worse than the defect. Caught by mutation-testing the fix, a step that was missing when ffda450 went up. The comment now avoids naming either identifier while keeping the substance. Verified: fixed tree passes; reintroducing the original ordering fails test-issue-1356-map-a11y.js; restoring it passes again. Full JS unit list from deploy.yml: 65/65.
|
Correction to my previous comment, and a self-inflicted one — pushed The explanatory comment I added in So for one push this PR did not fix the check — it disabled it. A grep-based assertion that a comment can satisfy is worse than one that fails on refactors, because green stops meaning anything.
Full JS unit list from The numbers in my earlier comment stand — the gap measurements and the point that moving This is a fairly direct argument for replacing that assertion with a check on the emitted label string rather than on source layout. A source grep cannot distinguish code from a comment about the code. Happy to open that as a separate PR against #1356 if it is wanted. |
|
One more correction, and this one is against my own earlier comment. I told you the gap was "299, not 312". Both numbers are the distance between the two declarations — and that is not the distance the regex uses. It is satisfied through the other alternative: Measured on the pair that actually matters:
So your suggestion does not fall 33 characters short — it moves the gap the wrong way, because I have opened #1933 against |
|
You are right on both counts and my advice was worse than wrong, it was actively harmful. Taking them in order. The pair I measured is not the pair the regex uses. The assertion has two alternatives, and on My suggested fix moves the gap the wrong way. 277 → 343 by your table, because And the I have approved the workflow run on #1933 is the right follow-up and I will review it on its own merits. Replacing a source grep with a check on what the function renders is worth more than this PR is, in the long run: right now the a11y guarantees in that file are pinned by where identifiers sit in the source, which is a promise about formatting rather than about behaviour. My review verdict on this PR is unchanged: approve. The regression guard you added, grepping |
|
Recycling again, and again the failure is not yours.
Master is fixed now. Closing and reopening to get you a run against it. |
…e route type (#1913) ## Summary `computeNodeHashSizeInfo` skips zero-hop direct adverts by **route type**. It should skip them by the **content of the path byte**, because the two cases are no longer the same thing. A zero-hop direct advert carries no path, so its hop count is 0. Whether the two size bits next to it mean anything depends on the sender: - Firmware that predates [meshcore-dev/MeshCore#3293](meshcore-dev/MeshCore#3293) does `packet->path_len = 0` in `Mesh::sendZeroHop()`, wiping the whole byte including the size bits. `0x00` genuinely says nothing about the node's `path.hash.mode` — skipping it is right, and #649 was right. - A sender that writes the size through `setPathHashSizeAndCount()` emits `0x40` (2 bytes) or `0x80` (3 bytes) with a zero hop count. On a zero-hop packet nothing else can set those bits, so they are a deliberate declaration. #653 landed the skip as `pathByte & 0x3F == 0`, which swallows the second case too. The diagnosis in #649 had actually proposed `pathByte == 0x00`; the review widened it on the reasoning that a zero hop count always implies zeroed size bits. That was true in April, when no firmware wrote them. It is not true now. On the Czech mesh (869.4 MHz), a 24h window of 10k packets holds **54 zero-hop direct adverts: 39 at `0x00` and 15 carrying a declared size** (14× `0x40`, 1× `0x80`). ## Why it matters for display, not just tidiness Measured on one node over a 7-day window. A companion was reconfigured from a 2-byte to a 3-byte path hash. Its first advert under the new setting was a zero-hop direct one on **24 Aug 15:36 UTC** declaring `0x80`. That packet was dropped, so the node kept reading as 2-byte until its next **flood** advert arrived on **25 Aug 10:18 UTC** — 18h42m serving a configuration the analyzer had already been told was stale, confirmed against both an unpatched and a patched instance. With local adverts typically every 2h and flood adverts every 25h, that gap is the normal case rather than a corner one. It bites hardest on an instance whose retention window is shorter than a flood advert interval: there the node has *no* countable advert at all and falls out of `hash_size` entirely (which is what #1912 is about on the rendering side). ## Change `(pathByte & 0x3F) == 0` → `pathByte == 0x00`, in `computeNodeHashSizeInfo` and in `computeAnalyticsHashSizes` so the two views agree. `isZeroHop` renamed to `isUndeclaredZeroHop` in the latter, since that is now what it means. No complexity change — same single byte comparison inside the existing scan. ## Measured A/B Two builds of the **same commit**, one with the change, both run read-only against the same copy of a real 181k-transmission / 973-node database: | | baseline | patched | |---|---|---| | nodes changed | — | **1** | | nodes regressed | — | **0** | | `hash_size_inconsistent` | 6 | **6** | | `multi_byte_status` split | 726 / 161 / 86 | unchanged | The flip-flop flag not moving is the point worth checking: a node that legitimately changes its mode mid-window is still handled by the recency decay from #1788, so reading these packets does not resurrect false "varies". ## Tests `cd cmd/server && go test ./...` → **ok**, 0 failures. Coverage 83.5%, unchanged from master. 5 new cases in `cmd/server/zerohop_hashsize_test.go`, two built from real off-air packets: - zero-hop DIRECT `0x40` → `HashSize 2` (was: dropped) - zero-hop DIRECT `0x80` → `HashSize 3` - zero-hop DIRECT `0x00` → still absent from the map, i.e. #649's behaviour preserved - TRANSPORT_DIRECT at path-byte offset 5, declared vs wiped - the declared size reaching `computeMultiByteCapability` as `confirmed`, which is what the map's multi-byte overlay reads **One existing test changed, flagging it explicitly:** `TestHashSizeTransportDirectZeroHopSkipped` used `0x40` as its "should be skipped" fixture. It now uses `0x00` — the case it was written to cover, since #747 was about the missing `RouteTransportDirect` skip rather than about the size bits. The `0x40` case is covered by the new tests with the opposite expectation. ## Deliberately not touched The decoders (`cmd/server/decoder.go:648`, `cmd/ingestor/decoder.go:1045`) still report `HashSize 0` for these packets, so per-packet views keep showing the size as unknown. Arguably they should follow the same rule, but that changes packet display rather than node attribution and felt like a separate call for you to make. ## Caveat worth stating This attributes a declared size to the pubkey inside the advert. That holds as long as the advert was transmitted by the node that owns it — the same assumption the existing zero-hop **flood** path already makes, so this change does not widen it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Recycling both of your PRs, and it is worth saying why, because it is a trap that has cost this queue a lot of time today. Both of your runs are That fully explains #1912. Its re-run at 11:44 still tests a base from before #1932, which is the fix for the master breakage I caused this morning by merging #1929 and #1930 together. So it keeps failing on For #1933 it is less clear-cut. Its run started at 09:31, which is the same minute #1932 merged, so its base may or may not have included the fix. The failure there was I said I would look properly if it failed twice rather than send you chasing it, so: I read your Closing and reopening both, which forces a new merge ref against current master. Nothing needed from you. |
The bug
map.jsturns a missinghash_sizeinto1:That field is evidence, not a default —
computeNodeHashSizeInfopopulates it only from adverts it could read a size out of, so a node with no countable advert in the retention window has no value at all. Rendering that absence as1states a 1-byte configuration nobody observed, and it does so in the one place where a reader is most likely to act on it.It is also inconsistent with the rest of the UI for the same field on the same node:
nodes.js:683Hash Prefix: **Unknown**analytics.js:1553(**?**B)map.js:140,:1588,:1775C8 **(1B)**On analyzer.meshcore.cz right now, 701 of 1007 nodes have
hash_size: null, so the map's 1-byte bucket is mostly nodes that were never measured. The Byte Size filter has the same problem from the other end: picking "1-byte" returns measured 1-byte nodes and every unheard node, which makes it hard to use for the thing it exists for.The fix
roles.js: sharedhashPrefixInfo(node)→{known, bytes, prefix}, so the map stops re-deriving the prefix in three places and the "unknown" rule lives in one.map.js:.hash-unconfirmedand itsaria-labelsays…, hash size unknown;Unknown, matchingnodes.jswording;Unknownbucket instead of folding unmeasured nodes into 1-byte.style.css: dotted underline for the unconfirmed prefix — a shape cue rather than a colour one, so it survives forced-colors and colour-vision differences, consistent with the a11y(map): cluster bubbles + role pills + multi-byte hash labels encode signal by color only (WCAG 1.4.1) #1356 approach for these labels.nodes.jsandanalytics.jsare left alone: they already behave correctly, and switching them to the helper would widen the diff without changing behaviour. Happy to do it in a follow-up if you'd rather have the call site count at zero.Tests
node test-frontend-helpers.js→ 635 passed, 2 failed; the two failures arefavStar, pre-existing on master (baseline run before this change: 625 passed, 2 failed — same two).10 new cases:
hashPrefixInfoacross missing / null / 0 / 1 / 2 / 3-byte inputs plus missing pubkey and a null node, and a guard assertingmap.jscontains no barehash_size || 1so this cannot quietly come back.Browser validation
Headless Chromium against a live instance carrying real mesh data, same viewport (
#/map?lat=50.038502&lon=14.570556&zoom=17), unpatched vs patched:aria-labelrepeater hash C8repeater hash C8, hash size unknownmc-mb-labelmc-mb-label hash-unconfirmedall,1,2,3all,1,2,3,unknownThe four nodes in that viewport that do have evidence (
157E,381E,FA74,C029) render exactly as before.