diff --git a/cmd/server/nodes_pagination_has_more_test.go b/cmd/server/nodes_pagination_has_more_test.go new file mode 100644 index 00000000..de1eada4 --- /dev/null +++ b/cmd/server/nodes_pagination_has_more_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// TestNodesHasMoreSurvivesPostLimitFiltering pins the contract that lets a +// client paginate /api/nodes safely. +// +// handleNodes applies the geo-filter, blacklist, hidden-prefix and area passes +// AFTER the SQL LIMIT/OFFSET, and rewrites Total to the filtered length. So a +// page that loses a row is short without being the last page, and neither the +// page length nor Total can tell a client whether to ask for another page. +// has_more is computed from the raw SQL page against the real COUNT(*), before +// those passes run, and must therefore stay true on a page that filtering +// shortened. +// +// Anti-tautology: move the hasMore assignment below the filter block (or +// compute it from the filtered slice) and the page-1 assertion fails — that is +// exactly the arrangement that stranded every node behind a filtered row. +func TestNodesHasMoreSurvivesPostLimitFiltering(t *testing.T) { + srv, router := setupTestServer(t) + + // setupTestServer seeds its own fixture nodes; clear them so the page + // boundaries below are exactly the ones this test sets up. + if _, err := srv.db.conn.Exec(`DELETE FROM nodes`); err != nil { + t.Fatalf("clear fixture nodes: %v", err) + } + + // 7 nodes, newest first by last_seen so page order is deterministic. + for i := 0; i < 7; i++ { + name := fmt.Sprintf("visible-%d", i) + if i == 1 { + name = "🚫 hidden-1" // lands inside page 1 (offset 0, limit 3) + } + lastSeen := fmt.Sprintf("2026-06-0%dT00:00:00Z", 7-i) + if _, err := srv.db.conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, ?, 'repeater', 0, 0, ?, '2026-06-01T00:00:00Z', 1)`, + fmt.Sprintf("deadbeef0000200%d", i), name, lastSeen); err != nil { + t.Fatalf("insert %d: %v", i, err) + } + } + srv.cfg.SetHiddenNamePrefixes([]string{"🚫"}) + + page := func(offset int) NodeListResponse { + req := httptest.NewRequest("GET", fmt.Sprintf("/api/nodes?limit=3&offset=%d", offset), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("offset %d: status %d body=%s", offset, w.Code, w.Body.String()) + } + var got NodeListResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("offset %d: decode: %v", offset, err) + } + return got + } + + // Page 1 is a row short because the hidden node was dropped after the LIMIT. + first := page(0) + if len(first.Nodes) != 2 { + t.Fatalf("page 1: expected 2 rows after filtering, got %d", len(first.Nodes)) + } + if !first.HasMore { + t.Fatalf("page 1: has_more must stay true on a page shortened by filtering " + + "(4 more nodes are waiting) — a client stopping here strands them") + } + + // Walking has_more reaches every visible node, including the last page. + seen := map[string]bool{} + for offset, more := 0, true; more && offset < 100; offset += 3 { + p := page(offset) + for _, n := range p.Nodes { + pk, _ := n["public_key"].(string) + seen[pk] = true + } + more = p.HasMore + } + if len(seen) != 6 { + t.Fatalf("expected all 6 visible nodes across the walk, got %d", len(seen)) + } + + // The final page reports has_more=false rather than relying on a short page. + if last := page(6); last.HasMore { + t.Fatalf("final page: has_more must be false, got true") + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 80053fd9..924f23e5 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -208,9 +208,10 @@ func componentSchemas() map[string]interface{} { "NodeListResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "nodes": map[string]interface{}{"type": "array", "items": schemaRef("Node")}, - "total": map[string]interface{}{"type": "integer", "description": "Total nodes matching the query after filtering."}, - "counts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Per-role node counts."}, + "nodes": map[string]interface{}{"type": "array", "items": schemaRef("Node")}, + "total": map[string]interface{}{"type": "integer", "description": "Total nodes matching the query after filtering."}, + "counts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Per-role node counts."}, + "has_more": map[string]interface{}{"type": "boolean", "description": "True when rows exist past this page. Decided before the blacklist / hidden-prefix / geo-filter / area passes, which drop rows from the page and rewrite total — so neither the page length nor total can be used to stop paginating. Paginate until this is false."}, }, }, "NodeDetailResponse": map[string]interface{}{ diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 8bf16b78..b9dbcdc0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1334,9 +1334,10 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() + limit := queryLimit(r, 50, s.cfg.ListLimits.NodesMax) + offset := queryInt(r, "offset", 0) nodes, total, counts, err := s.db.GetNodes( - queryLimit(r, 50, s.cfg.ListLimits.NodesMax), - queryInt(r, "offset", 0), + limit, offset, q.Get("role"), q.Get("search"), q.Get("before"), q.Get("lastHeard"), q.Get("sortBy"), q.Get("region"), ) @@ -1344,6 +1345,14 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + // Whether more rows exist is decided HERE, against the raw SQL page and the + // real COUNT(*), because both other candidate signals are destroyed further + // down: the geo-filter / blacklist / hidden-prefix / area passes drop rows + // from this page AND rewrite `total` to the filtered length. A page that + // loses a row is then short without being the last page, so a client that + // stops on a short page strands every node behind it (#1606 fixed the + // no-filter case only). has_more survives those passes untouched. + hasMore := offset+len(nodes) < total if s.store != nil { hashInfo := s.store.GetNodeHashSizeInfo() relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours @@ -1513,7 +1522,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { total = len(filtered) } } - writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts}) + writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts, HasMore: hasMore}) } func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/server/types.go b/cmd/server/types.go index 40b3fa0b..bc23faef 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -435,6 +435,12 @@ type NodeListResponse struct { Nodes []map[string]interface{} `json:"nodes"` Total int `json:"total"` Counts map[string]int `json:"counts"` + // HasMore reports whether rows exist past this page. Computed from the raw + // SQL page before the post-LIMIT filters in handleNodes, which shorten the + // page and rewrite Total — so it is the only field a paginating client can + // trust to decide whether to ask for another page. Always emitted (no + // omitempty): a client must be able to tell "false" from "old server". + HasMore bool `json:"has_more"` } type NodeSearchResponse struct { diff --git a/public/app.js b/public/app.js index 9d9485bb..28b97b39 100644 --- a/public/app.js +++ b/public/app.js @@ -237,11 +237,25 @@ async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyC : (Array.isArray(data) ? data : []); accumulated.push.apply(accumulated, page); if (offset === 0) counts = (data && data.counts) || {}; - // Canonical stop: a short page is the end. The server's `total` is a real - // COUNT(*) for the query, but the handler overwrites it with the filtered - // length under area/geo/blacklist filtering — so we never loop on it, nor - // surface it; a short page is the reliable end-of-data signal. See #1606. - if (page.length < pageSize) break; + // Canonical stop: the server's `has_more`. Neither of the other two signals + // can be trusted — the handler rewrites `total` to the filtered length, AND + // the same filters (blacklist / hidden prefix / geo-filter / area) drop rows + // from the page itself, so a page can be short while later pages still hold + // rows. #1606 stopped on a short page, which is correct only on deployments + // where nothing is ever filtered; elsewhere one filtered node in page 1 + // stranded every node behind it. `has_more` is computed server-side before + // those filters run. + // An empty page always ends it: there is nothing here, and OFFSET means + // nothing behind it either. Checked first so a server reporting has_more + // against a concurrently-shrinking table cannot spin us to safetyCap. + if (page.length === 0) break; + if (data && typeof data.has_more === 'boolean') { + if (!data.has_more) break; + continue; + } + // Server predates `has_more`: fall back to a zero-length page, the only + // remaining end-of-data signal that filtering cannot fake. Costs one extra + // request per load against an old server; a short page no longer stops us. } // Dedup by public_key: the sort window (last_seen DESC by default) can shift // under concurrent ingest, repeating a row across a page boundary. Rows diff --git a/public/area-map.html b/public/area-map.html index 3c30b48f..5f6d7290 100644 --- a/public/area-map.html +++ b/public/area-map.html @@ -239,7 +239,11 @@ async function fetchAllNodesPaged(extra) { const d = await r.json(); const page = Array.isArray(d) ? d : (d.nodes || []); out.push.apply(out, page); - if (page.length < PAGE) break; + // has_more, not page length: /api/nodes filters rows out AFTER the SQL + // LIMIT, so a short page can still have pages behind it. Zero-length is + // the fallback for a server that predates the flag. + if (page.length === 0) break; + if (typeof d.has_more === 'boolean' && !d.has_more) break; } // Dedup by public_key; rows missing one get a unique key so they aren't collapsed. const seen = new Map(); diff --git a/public/nodes.js b/public/nodes.js index 05f98a72..0b4a355f 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -1232,9 +1232,10 @@ try { // Fetch all nodes via pagination loop — server clamps /api/nodes ?limit // to 500 (PR #1540 / v3.8.3 DoS guard), so a single fetch silently - // truncates large deployments. Loop exit uses data.nodes.length < PAGE_SIZE - // as canonical stop — server total is unreliable under area filters - // (routes.go:1357 overwrites total = len(filtered)). See #1606. + // truncates large deployments. Loop exit uses data.has_more — server + // total is unreliable under area filters (overwritten with + // len(filtered)), and so is the page length, since the same filters drop + // rows from the page. See #1606 and the has_more note in app.js. if (!_allNodes) { const PAGE_SIZE = 500; const SAFETY_CAP = 10000; // hard ceiling to bound runaway loops @@ -1263,8 +1264,16 @@ const estTotal = firstTotal || '?'; nodesBody.innerHTML = '