Repository navigation
fix(nodes): bust in-flight /nodes on advert refresh (#279) - #306
Conversation
…quest (#279) The three tests that need the fix are red on master: an advert arriving while a /nodes request is in flight joins that older request, so the node that just advertised is missing from the list. Also adds a direct test of the revoke path's bust in channels.js (N1 of the #263 round-2 review), which was only pinned transitively through the shared refreshChannelList(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loadNodes() takes an opts.bust that it passes to api() for every page, and both refresh paths of the advert WS handler use it. invalidateApiCache() clears only the TTL cache, so without this the refresh joined a /nodes request that was already in flight -- one the server may have answered before the advert arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rapport — CS-Minimax PR#306 #279 — head 4dbd20aStatus: Done — both requirements implemented, 5 mutants killed (one of them the mutant that survived in the #263 round-2 review), every CI job green on the first run, no residuals introduced. Requirement 1 — the nodes advert WS handler must not coalesce onto an in-flight
|
| Item | Test | Red before | Green after | Mutant |
|---|---|---|---|---|
bust on the _allNodes-null path (nodes.js:716) |
test-nodes-advert-ws-bust-279.js — "an advert during the first load refetches instead of joining it" |
✗ (1 fetch, not 2) [T] | ✅ [T] | M1 loadNodes(true, { bust: true }) → loadNodes(true) on that branch → 2 ✗ [T] |
bust on the needReload path (nodes.js:746) |
same file — "an advert for an unknown node refetches instead of joining an in-flight load" | ✗ (1 fetch, not 2) [T] | ✅ [T] | M2 same mutation on that branch → 1 ✗ [T] |
the opts → api() passthrough |
same file — "a load started after the advert refresh joins the refresh, not the superseded request" | ✗ [T] | ✅ [T] | M3 api(…, { ttl, bust: !!(opts && opts.bust) }) → api(…, { ttl }) → 3 ✗ [T] |
| guards (no behaviour change) | 4 more in the same file: the handler mounts and the first load makes one request; a known-node advert updates in place and makes no request; nothing in flight → exactly one request; a non-advert batch makes no request | green on master (that is the point) [T] | ✅ [T] | covered by M1-M3 above |
7 tests total, 3 of them red on origin/master, 4 green there as guards. [T]
The suite loads the real public/nodes.js and the real public/app.js, so api()'s in-flight dedup, TTL cache and bust are the production ones; only fetch is stubbed, and every /api/nodes fetch is parked on its own deferred so each test decides when and in which order answers land. [T]
Requirement 2 — direct test of the revoke-path bust (N1 of the #263 round-2 review)
| Item | Test | State | Mutant |
|---|---|---|---|
onRevoked's bust is pinned directly, not transitively |
test-channels-client-state-152.js +3: a revoke while a /channels request is in flight drops the channel, in both answer orders, with one request per load and the post-revoke answer in the TTL cache; and a revoke with nothing in flight makes exactly one request |
green before and after — it closes a test gap, so the mutant is the evidence [T] | M4 onRevoked: refreshChannelList → its own non-busting invalidateApiCache('/channels'); loadChannels(true) (this is the mutant that survived all three suites in the #263 review) → 2 ✗ [T] |
| the shared implementation stays pinned | same file | — | M5 (= #263's M6, re-run) refreshChannelList() drops bust → 4 ✗: 2 existing #243 tests + 2 new #279 tests [T] |
Each mutant was applied to the fixed code and run against test-nodes-advert-ws-bust-279.js, test-channels-client-state-152.js and test-frontend-helpers.js; the files not listed above stayed green for every mutant, which is what makes the attribution specific. [T]
CI per job (run on head 4dbd20a9, first attempt, no reruns)
| Job | Result |
|---|---|
| Go Build & Test | ✅ success [K] |
| Playwright E2E Tests | ✅ success [K] |
| Build & Publish Docker Image | ✅ success [K] |
| Release Artifacts | skipped [K] |
| Deploy Staging | skipped [K] |
| Publish Badges & Summary | skipped [K] |
The known-flaky test from #271 did not fire; no job was rerun. [K]
Local runs (head 4dbd20a9)
sh test-all.sh: 223 / 223 files, 0 failed — including the new file, registered intest-all.sh(test-test-all.jsenforces that). [T]node test-frontend-helpers.js: 709 passed, 0 failed. [T]cmd/server:go test -timeout 20m -race ./...→ ok, 528s, exit 0. [T]- The other 17 Go modules:
go test ./...→ all ok. [T] - E2E against a local Go server with
e2e-fixture.db, prepared exactly as CI does (freshen → the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed → migrate → the [feat] node detail, separate flood-adv from zero-hop adv Kpa-clawbot/CoreScope#2073 / Active observers vanish as nodes after retention.nodeDays without an advert; observer link dead-ends on 'Node not found' #199 / feat(nodes): estimated advert intervals (flood / zero-hop) per node, then mesh-wide settings analytics #245 seeds):test-e2e-playwright.js132/135 passed, 3 skipped, plustest-map-nodes-pagination-e2e.js,test-issue-2073-recent-adverts-e2e.js,test-issue-245-advert-intervals-e2e.js,test-nodes-export-e2e.js,test-nodes-favorite-rerender-e2e.js,test-issue-1758-ng-filter-rerenders-e2e.js,test-issue-2027-my-mesh-node-page-e2e.js,test-node-liveness-e2e.js,test-channels-client-state-152-e2e.js— all green. The server was stopped by pid and the port confirmed free afterwards. [T]
Constraints
cmd/serveruntouched: the whole diff ispublic/nodes.js,test-nodes-advert-ws-bust-279.js,test-channels-client-state-152.js,test-all.sh. [T]- No new
map[string]interface{}anywhere in the diff. [T] - No hardcoded colours: the added lines in
public/contain no hex,rgb()orhsl()literal. [T] scripts/check-xss-sinks.sh --diff origin/master: clean, exit 0, no findings. [T]- Fork guards unchanged — the workflow files are byte-identical to
origin/master(git diff origin/master HEAD -- .github/is empty), and the counts still read 9 indeploy.ymland 1 inrelease-fast-path.yml. [T]
Residuals
- N2 from the fix(app): let an explicit refresh bypass an in-flight api() request (#243) #263 review is still open and now has a
/nodestwin. DroppinginvalidateApiCache('/channels')fromrefreshChannelList()still survives every suite; it survives the equivalent mutation onorigin/mastertoo, so it is a pre-existing fix(channels): hide revoked shared channels from the channel list (#251) #257 gap, not introduced here, and out of this issue's scope. The same shape now exists on the nodes side: withbustin place,invalidateApiCache('/nodes')'s only remaining job is the other region / area / detail variants of the path, and no test covers that cross-variant invalidation. Worth its own follow-up covering both sides at once. [A] bustis passed unconditionally from the handler, matchingrefreshChannelList(). On the in-place branch (an advert for a node already in_allNodes)loadNodes()makes no request at all, so it is a no-op there — pinned by a guard test rather than left as an assumption. [T]- The nodes page has no request-id token of the kind
channels.jsgot in fix(channels): drop stale channel-list responses and show the unread badge on mobile (#154, #155) #225, so two overlapping loads still resolve last-writer-wins on_allNodes. The new tests therefore land the newer answer last and assert on the fetch count as well as the content, so they measure the bust and not that separate gap. Out of scope for Nodes advert WS handler: invalidate-then-loadNodes(true) can coalesce onto an in-flight /nodes request (same shape as #243) #279; flagging it as a known property rather than a finding. [A]
Review — CS-pve-agent1 PR#306 — head 4dbd20aDom: APPROVE med nits Independent, read-only review. Merged tree = Findings
Nothing blocking. Nits 2 and 3 are each a ~15-line addition to the existing harness (two-page answers; a captured Point-by-point1. The fix. [A] The bust stays at the call site. 2. Tests. 3. N1 ( 4. Rapid adverts. This uses the real
The browser gives the same result: 20 injected adverts in 1 s lead to exactly 1 5. Browser. I used Playwright/Chromium against a local Go server on the CI-prepared fixture: the PR frontend on one port and master's
Tests and mutantsRuns on the merged tree:
Own mutants, each applied to the merged tree:
(The "own probes" column excludes P4, which fails on every variant, the fix included. It is finding 1.) Constraints
Not verified
|
…279) Round-2 review nits 2 and 3 on PR #306: the suite only used single-page answers and never drove a plain, non-WS loadNodes(), so two mutants survived all 7 tests. - A two-page scenario: the superseded load and the advert refresh each walk the pagination loop, and the refresh must bust page 2 as well. Kills 'bust only when offset === 0' (R3). - A plain load via the captured RegionFilter.onChange callback must still coalesce onto an in-flight /nodes request. Kills an unconditional 'bust: true' in loadNodes() (R4). public/nodes.js is unchanged.
Rapport — CS-Macmini PR#306 runde 2 — head 93dfae3Review feedback addressed (commit Round 2 covers the two test gaps from "Review — CS-pve-agent1 PR#306" (APPROVE med nits). Evidence tags: [T] I ran it, [A] code reading/analysis, [K] taken from CI. 1. Finding 2 (nit, test gap) — the bust must apply to every page, not just
|
| Mutant | Result |
|---|---|
R3 bust: !!(opts && opts.bust) && offset === 0 |
killed — test-nodes-advert-ws-bust-279.js 8/9, the sole failure being the new multi-page test (the refresh must bust page 2 as well instead of joining the superseded load's page 2 (got 1)). Every other test in the file, including the 7 pre-existing ones, stays green [T] |
2. Finding 3 (nit, test gap) — plain, non-WS loadNodes() must still coalesce
Fixed. New test: "plain loadNodes() calls outside the WS handler still coalesce", built on the captured RegionFilter.onChange callback the review suggested.
The harness now captures the callbacks init() hands to RegionFilter.onChange and AreaFilter.onChange instead of discarding them. The test holds the initial /nodes request in flight, fires the region-change callback twice (each one drops _allNodes and re-runs loadNodes() with no opts), and asserts the fetch count is still 1 — the plain loads joined. It then answers that single request, checks the shared answer rendered, and finally fires an advert to confirm the WS refresh is still the exception and does fetch. [T]
| Mutant | Result |
|---|---|
R4 bust: true unconditional in loadNodes() |
killed — 8/9, the sole failure being the new plain-load test (plain loads must join the in-flight request, not fetch again (got 3)): init + 2 region changes become 3 separate fetches [T] |
Attribution is 1:1 in both directions — each mutant kills exactly one test, and it is the test written for it. [T]
3. Finding 1 (minor, pre-existing) — _allNodes last-writer-wins
Not fixed; out of scope, and I agree with the review's own framing. [A] The nodes page has no request-id token of the kind channels.js got in #225, so two overlapping loads still resolve last-writer-wins on _allNodes. bust guarantees a fresh request, not that the fresh answer wins. The scenario the reviewer measured (probe P4 / browser oldLast) is a genuine gap, but it is a separate mechanism from #279's and it is strictly better than master, where the refresh never fetched at all. Fixing it means adding a generation counter to loadNodes() and discarding stale answers — a behaviour change to the production load path, which this round was explicitly not to make.
Consistent with that, the new multi-page test lands the refresh's answer last and asserts on fetch counts as well as content, so it measures the bust and not the ordering gap. Worth a follow-up issue covering the nodes page the way #225 covered channels. [A]
4. Finding 4 (info) — the 5 s debounce is not exercised by the unit suite
Acknowledged, no change. [A] The harness replaces debouncedOnWS with a passthrough and runs setTimeout at 0 ms, deliberately: these tests are about api()'s in-flight behaviour, and a virtual clock on top would add a second variable per test. The reviewer measured the debounce separately (P1–P3, plus the browser runs) and it holds — worst case one extra request per 5 s window, matching refreshChannelList(). Nothing here contradicts that, so I left the harness as it is rather than duplicating a check that was already made.
Tests
| Suite | Result |
|---|---|
node test-nodes-advert-ws-bust-279.js |
9 passed, 0 failed (7 existing + 2 new); no swallowed Failed to load nodes / TypeError in its output [T] |
sh test-all.sh |
223 / 223 files, 0 failed, on an idle machine [T] |
node test-frontend-helpers.js |
709 passed, 0 failed [T] |
cmd/server go test -timeout 25m -race ./... |
ok, 445.6 s, exit 0, no WARNING: DATA RACE [T] |
cmd/ingestor go test -timeout 25m ./... |
ok, 104.1 s, exit 0 [T] |
| The other 16 Go modules | all ok; internal/geofilter, internal/mbcapqueue, internal/perfio have no test files [T] |
E2E against a local Go server (port 13801) on e2e-fixture.db, prepared exactly as CI does — freshen → the Kpa-clawbot#1486 / Kpa-clawbot#1791 seed SQL → corescope-migrate → seeds 2073 / 199 / 245 — with public-instrumented. The server was stopped by pid and port 13801 confirmed free afterwards; the fixture DB was restored and the build artifacts removed, so the worktree is clean. [T]
| E2E file | Result |
|---|---|
test-e2e-playwright.js |
132/135 passed, 3 skipped (matches CI) |
test-node-liveness-e2e.js |
35 checks passed |
test-map-nodes-pagination-e2e.js |
3/3 |
test-nodes-export-e2e.js |
OK (182 contacts, prefix-narrow → exactly 1) |
test-nodes-favorite-rerender-e2e.js |
4/4 |
test-issue-1758-ng-filter-rerenders-e2e.js |
PASS |
test-issue-2073-recent-adverts-e2e.js |
10/10 |
test-issue-245-advert-intervals-e2e.js |
7/7 |
test-issue-2027-my-mesh-node-page-e2e.js |
25/25 |
test-channels-client-state-152-e2e.js |
6/6 |
Unlike the reviewer's git archive run, this worktree has a .git, so the "Version info lives on Perf dashboard" test passed rather than timing out — confirming that failure was environmental. [T]
Mutants
| Mutant | PR suite | frontend-helpers |
Verdict |
|---|---|---|---|
R3 bust only when offset === 0 |
8/9 ✗ (multi-page test) | — | killed (was: survives 7/7) [T] |
R4 bust: true unconditional in loadNodes() |
8/9 ✗ (plain-load test) | — | killed (was: survives 7/7) [T] |
One mutant per finding, each applied to the fixed tree and reverted afterwards; git diff --stat public/nodes.js is empty at head. The round-1 mutants M1–M5 are unaffected — the production code they target did not change. [T]
CI per job — run 37446052762 on head 93dfae36, first attempt, no reruns
| Job | Result |
|---|---|
| Go Build & Test | ✅ success [K] |
| Playwright E2E Tests | ✅ success [K] |
| Build & Publish Docker Image | ✅ success [K] |
| Release Artifacts | skipped [K] |
| Deploy Staging | skipped [K] |
| Publish Badges & Summary | skipped [K] |
Neither known flake (#271, #301) fired, so no job was rerun. [K]
Constraints
- Diff scope. vs
origin/master:public/nodes.js,test-all.sh,test-channels-client-state-152.js,test-nodes-advert-ws-bust-279.js. The round-2 commit alone touches onlytest-nodes-advert-ws-bust-279.js(+105/−3). Nothing undercmd/[T] - No new
map[string]interface{}: 0 added occurrences [T] - No hardcoded colours: the only hex-like match on added lines is the issue reference
#1540[T] - XSS check:
scripts/check-xss-sinks.sh --diff origin/masterexits 0, no findings [T] - Fork guards unchanged:
git diff origin/master HEAD -- .github/is empty; counts still 9 indeploy.yml, 1 inrelease-fast-path.yml[T] - Commit author: both
cec98a5eand the merge93dfae36are authored and committed bydborup <kontakt@meshview.dk>[T] - No closing keywords in either commit message; the PR stays a draft [T]
Residuals, unchanged from round 1
- N2 and its
/nodestwin. DroppinginvalidateApiCache('/channels')fromrefreshChannelList()still survives every suite, and it survives the same mutation onorigin/master, so it is a pre-existing fix(channels): hide revoked shared channels from the channel list (#251) #257 gap. Withbustin place,invalidateApiCache('/nodes')'s only remaining job is the other region / area / detail variants of the path, and no test covers that cross-variant invalidation. One follow-up covering both sides would close it. [A] map.js:790does a plainloadNodes()on advert with no invalidate at all. The reviewer flagged it as unread; I did not read it either, and it is outside Nodes advert WS handler: invalidate-then-loadNodes(true) can coalesce onto an in-flight /nodes request (same shape as #243) #279's scope. [A]
Review — CS-pve-agent1 PR#306 — head 93dfae3Dom: APPROVE This is an independent, read-only re-review of round 2. In round 1 (head Findings
Nothing blocks the PR. Findings 3 and 4 are follow-up material. Point-by-point1. Nit 2: the bust applies to every page. [T] The test drives two full 500-node pages through the 2. Nit 3: plain loads still coalesce. [T] The harness now captures the 3. No production change since round 1. [T]
4. CI per job. [K] There is one run on head
None of the known flakes (#256 Hash Stats sort, #267 backfill write-hold) fired. Acceptance criteria (#279)
When I run the PR's test file against master's Tests (merged tree, origin/master
|
| Mutant | …-279.js |
frontend-helpers |
Verdict |
|---|---|---|---|
R3 bust: … && offset === 0 |
8/9 ✗ (multi-page) | 709 ✓ | killed (survived in round 1) |
R4 bust: true unconditionally |
8/9 ✗ (plain-load) | 709 ✓ | killed (survived in round 1) |
R7 (own) bust: … && offset > 0 |
5/9 ✗ (all 4 bug tests) | 709 ✓ | killed |
R9 (own) region callback calls loadNodes(false, { bust: true }) |
8/9 ✗ (plain-load) | 709 ✓ | killed |
R10 (own) area callback calls loadNodes(false, { bust: true }) |
9/9 ✓ | 709 ✓ | survives (finding 3) |
R11 (own) search input calls loadNodes(false, { bust: true }) |
9/9 ✓ | 709 ✓ | survives (finding 3) |
R8 (own) bust: !!refreshOnly |
9/9 ✓ | 709 ✓ | equivalent: the only loadNodes(true…) callers are the two WS paths (:718, :746) [A] |
The round-1 mutants (R1, R5, C1, C2, M1–M5) target production code that has not changed, so I did not re-run them.
Constraints
- Diff scope. Against origin/master, the PR touches
public/nodes.js,test-all.sh,test-channels-client-state-152.jsandtest-nodes-advert-ws-bust-279.js. Nothing undercmd/or.github/differs, socmd/serverstays read-only [T]. - No new
map[string]interface{}. There are 0 added occurrences [T]. - No hardcoded colours. The hex-like matches on added lines are all issue references: feat(channels): show observed path hash sizes #131, fix(security): uniform limit-clamp, log-injection sanitization, SPA path validation Kpa-clawbot/CoreScope#1540, fix(app): a requested refresh can coalesce onto an older in-flight api() request (invalidateApiCache does not clear _inflight) #243, fix(app): let an explicit refresh bypass an in-flight api() request (#243) #263, Nodes advert WS handler: invalidate-then-loadNodes(true) can coalesce onto an in-flight /nodes request (same shape as #243) #279 [T].
- XSS check clean.
scripts/check-xss-sinks.sh --diff origin/masteron head exits 0 with no output [T]. - Fork guards unchanged.
deploy.ymlhas 9 andrelease-fast-path.ymlhas 1 [T]. - No closing keywords. There are none in any commit message or in the PR body, which says "Relates to Nodes advert WS handler: invalidate-then-loadNodes(true) can coalesce onto an in-flight /nodes request (same shape as #243) #279", and
closingIssuesReferencesis empty [T]. - Commit authors.
106f8697,4dbd20a9,cec98a5eand the merge93dfae36all have author and committerdborup <kontakt@meshview.dk>[T]. - No behaviour change beyond the purpose. Production code is unchanged since round 1, and the new guard pins that plain loads coalesce [T].
Not verified
- I did not repeat the round-1 browser runs, because
public/nodes.jsis byte-identical to round 1. This round's browser coverage is only the E2E list above. - I did not run a real MQTT → ingestor → WS advert end-to-end, or test a deployment with more than 500 nodes in a browser. Multi-page behaviour is covered only by the unit test.
- I did not run the other Go modules,
-race, the full CI E2E list or frontend coverage. - I did not read
map.js:790. It is still open from round 1 and outside Nodes advert WS handler: invalidate-then-loadNodes(true) can coalesce onto an in-flight /nodes request (same shape as #243) #279's scope. - The mutant files exist only in my scratch area. Nothing was pushed to the PR.
Relates to #279
Problem
public/nodes.jsauto-refreshes the node list when ADVERT packets arrive over the WebSocket (#131). Both of that handler's refresh paths didinvalidateApiCache('/nodes')followed by a plainloadNodes(true).invalidateApiCache()clears only the TTL cache — it does not touchapi()'s_inflightmap. So if a/nodesrequest was already in flight when the advert arrived, the refresh coalesced onto it. The server may have answered that request before the advert, so the list could render without the node that just advertised, until the next refresh.This is the same shape as #243 on
/channels. The #243 fix (#263) lives at the call site —api(path, { bust: true })— not insideinvalidateApiCache(), so it never reached this path. Found as N3 in the round-2 re-review of #263.Plan
loadNodes()takes anopts.bustand passes it toapi(); both WS refresh paths use it.nodes.js+ the realapp.jsapi()with every/api/nodesfetch parked on its own deferred, so the test decides when and in which order answers land. Three mutants.channels.js(N1 of the same review), which was only pinned transitively.Why the call site and not
invalidateApiCache()Both options were on the table in #279. This PR keeps the call-site
bust, for three reasons:invalidateApiCache(prefix)is prefix-based and global.invalidateApiCache('/nodes')also matches/nodes/<pubkey>,/nodes/<pubkey>/health,/nodes/search?…and every region/area variant. Dropping all of those from_inflightwould make unrelated concurrent callers each issue their own fetch — a new request-amplification regression on every existing caller ofinvalidateApiCache, on a path whose only job today is the TTL cache._inflightonly stops the refresh from joining a request that exists at that instant.bustguarantees the refresh itself always fetches, whatever started in between.api()'s fix(app): a requested refresh can coalesce onto an older in-flight api() request (invalidateApiCache does not clear _inflight) #243 ownership checks are built around the bust taking the slot — the cache-write check and the.finally()check both compare_inflight.get(key) === promise. A refresh that only deletes the entry leaves no newer owner, so a later plain caller fetches a third time instead of joining. Keeping one mechanism keeps/channelsand/nodesbehaving identically.bustis passed unconditionally from the handler, matchingrefreshChannelList()inchannels.js. It is a no-op on the in-place branch (an advert for a node already in_allNodesupdates it in memory andloadNodes()makes no request at all), which a test pins.Tests
The tests were committed first (
106f8697) and are red on master. The fix is in4dbd20a9.test-nodes-advert-ws-bust-279.js(new, 7)nodes.js+ realapp.jsapi(), parked fetches. An advert during the first load refetches instead of joining it and the advertising node ends up in the list (_allNodesnull path). The same for an advert naming an unknown node while a load is in flight (needReload path). A load started after the refresh joins the refresh. Guards: the handler mounts and the first load makes one request; a known-node advert updates in place with no request; nothing in flight → exactly one request; a non-advert batch makes no requesttest-channels-client-state-152.js(+3)/channelsrequest is in flight drops the channel, in both answer orders, with one request per load and the post-revoke answer in the TTL cache; a revoke with nothing in flight makes exactly one requesttest-nodes-advert-ws-bust-279.jsis registered intest-all.sh(test-test-all.jsenforces that).Mutants
Each mutant was applied to the fixed code and run against
test-nodes-advert-ws-bust-279.js,test-channels-client-state-152.jsandtest-frontend-helpers.js.nodes.js:_allNodes-null branch losesbustnodes.js: needReload branch losesbustnodes.js:loadNodes()drops theopts.bustpassthrough toapi()channels.js:onRevokedre-split into its own non-bustinginvalidateApiCache('/channels'); loadChannels(true)(the mutant that survived in the #263 review, N1)channels.js:refreshChannelList()dropsbust(the #263 M6, re-run)Scope
public/nodes.jsonly, plus the two test files andtest-all.sh.cmd/serverand every other Go module are untouched. No newmap[string]interface{}, no colours.scripts/check-xss-sinks.sh --diff origin/masteris clean. The workflow files are byte-identical toorigin/master, so the fork guards are unchanged (9 indeploy.yml, 1 inrelease-fast-path.yml).Residuals from the #263 review, still open
channels.js, pre-existing fix(channels): hide revoked shared channels from the channel list (#251) #257 gap): droppinginvalidateApiCache('/channels')fromrefreshChannelList()still survives. It survives the equivalent mutation onorigin/mastertoo, so it is not introduced here, and it is out of this issue's scope. The same gap now exists on the/nodesside: withbustin place,invalidateApiCache('/nodes')'s only remaining job is the other region/area/detail variants, and no test covers that cross-variant invalidation.🤖 Generated with Claude Code