/*
* Reproducer for a top-K selection bug in Pi-hole FTL v6.7
* (GET /api/stats/top_domains, src/api/stats.c :: get_top_domains()).
*
* Introduced by commit 8f8fd45b ("API: heap-based top-K selection for
* domains and clients"), which replaced "allocate an array for ALL
* domains + qsort" with a bounded min-heap of capacity (count * 4).
*
* The bug
* -------
* The scan loop admits a domain into the heap when its name is not the
* privacy sentinel and its count is >= 1. It does NOT skip "phantom"
* slots whose name offset is 0 (domainpos == 0 -> getstr() returns "",
* which is the uninitialized/recycled sentinel; real names live at
* offset >= 1 because shmSettings->next_str_pos starts at 1).
*
* Those phantom slots are only discarded LATER, in the output loop:
*
* // Skip e.g. recycled domains (stats.c)
* if(top_domains[i].namepos == 0)
* continue;
*
* With the old unbounded array this was harmless: every domain fit, so
* skipping a phantom at output time never reduced the visible result.
* With the bounded heap (default count=10 -> capacity 40), phantom slots
* with count >= 1 CONSUME heap capacity and evict real domains, which are
* then never printed. The endpoint returns far fewer than `count` rows.
*
* This mirrors a real report: with the default request the endpoint
* returned only 2 domains (each > 40 queries) while ?count=100 returned
* the full, correct list of ~100 domains.
*
* This file isolates the exact heap/output logic from stats.c (no shared
* memory, no locks, no cJSON) and feeds it a synthetic domain table.
*
* Build & run:
* cc -Wall -Wextra -O2 -o repro repro.c && ./repro
*
* Expected output (demonstrating the bug):
* count= 10 -> 2 visible domains: [44 42]
* count=100 -> 100 visible domains: [44 42 35 32 ...]
* A correct implementation would return 10 visible domains for count=10.
*/
#include <stdio.h>
#include <stdlib.h>
/* Mirrors "struct top_entries" in src/api/stats.c (relevant fields only).
* namepos == 0 marks a recycled / uninitialized slot (getstr(0) == ""). */
struct top_entries {
int count;
unsigned int namepos;
};
/* Verbatim from src/api/stats.c: min-heap sift-down keeping the smallest
* element at the root so it can be replaced when a larger one is found. */
static void heap_sift_down(struct top_entries *heap, const unsigned int size, unsigned int i)
{
for(;;)
{
unsigned int min = i;
const unsigned int l = 2*i + 1, r = 2*i + 2;
if(l < size && heap[l].count < heap[min].count)
min = l;
if(r < size && heap[r].count < heap[min].count)
min = r;
if(min == i)
break;
const struct top_entries tmp = heap[i];
heap[i] = heap[min];
heap[min] = tmp;
i = min;
}
}
/* qsort subroutine, sort DESC (verbatim from src/api/stats.c). */
static int cmpdesc_te(const void *a, const void *b)
{
const struct top_entries *elem1 = (const struct top_entries*)a;
const struct top_entries *elem2 = (const struct top_entries*)b;
if (elem1->count > elem2->count)
return -1;
else if (elem1->count < elem2->count)
return 1;
else
return 0;
}
/* Faithful extraction of get_top_domains()'s selection + output.
* counts[] : entry_count per domain slot (blocked ? blockedcount : count-blockedcount)
* nameposs[]: domainpos per slot; 0 == phantom/recycled (skipped only at output)
* domains : number of slots scanned (counters->domains)
* count : requested top-K (the ?count= query parameter; default 10)
* Returns the number of VISIBLE domains emitted. */
static int get_top_domains_sim(const int *counts, const unsigned int *nameposs,
unsigned int domains, int count)
{
/* --- allocation (stats.c) --- */
const unsigned int k = count > 0 ? (unsigned int)count : 1u;
const unsigned int heap_cap = (k <= domains / 4) ? k * 4 : domains;
struct top_entries *top_domains =
heap_cap > 0 ? calloc(heap_cap, sizeof(struct top_entries)) : NULL;
if(heap_cap > 0 && top_domains == NULL)
return -1;
/* --- scan loop (stats.c): fill / evict the bounded min-heap --- */
unsigned int heap_size = 0;
int heap_ready = 0;
for(unsigned int domainID = 0; domainID < domains; domainID++)
{
const int entry_count = counts[domainID];
/* NOTE: the real code skips only the HIDDEN_DOMAIN privacy
* sentinel here. A phantom slot (namepos == 0) has getstr() == ""
* which is NOT HIDDEN_DOMAIN, so it is *not* skipped and is
* admitted to the heap below. This is the defect. */
if(entry_count < 1)
continue;
if(heap_size < heap_cap)
{
top_domains[heap_size].count = entry_count;
top_domains[heap_size].namepos = nameposs[domainID];
heap_size++;
}
else
{
if(!heap_ready)
{
for(int j = (int)(heap_size / 2) - 1; j >= 0; j--)
heap_sift_down(top_domains, heap_size, (unsigned int)j);
heap_ready = 1;
}
if(entry_count > top_domains[0].count)
{
top_domains[0].count = entry_count;
top_domains[0].namepos = nameposs[domainID];
heap_sift_down(top_domains, heap_size, 0);
}
}
}
if(heap_size > 1)
qsort(top_domains, heap_size, sizeof(*top_domains), cmpdesc_te);
/* --- output loop (stats.c): phantom slots are discarded only here --- */
int n = 0;
printf("count=%3d -> ", count);
for(unsigned int i = 0; i < heap_size; i++)
{
if(top_domains[i].namepos == 0) /* "Skip e.g. recycled domains" */
continue;
if(top_domains[i].count < 1)
continue;
printf("%s%d", n == 0 ? "[" : " ", top_domains[i].count);
if(++n >= count)
break;
}
printf("%s (%d visible)\n", n == 0 ? "[" : "]", n);
free(top_domains);
return n;
}
int main(void)
{
/*
* Synthetic domain table matching the reported instance:
* - 38 phantom slots (namepos == 0) with a leftover count of 43,
* i.e. counted domains whose name offset is 0.
* - the real top domains: 44, 42, 35, 32, 31, ...
* - a long tail of low-count real domains.
*
* At count=10 the heap capacity is 40. The 38 phantoms plus the two
* largest real domains (44, 42) fill the heap; every other real domain
* is evicted. The 38 phantoms are then skipped at output -> only 2 rows.
*/
int counts[512];
unsigned int nameposs[512];
unsigned int m = 0;
for(int i = 0; i < 38; i++) { counts[m] = 43; nameposs[m] = 0; m++; }
const int real[] = { 44, 42, 35, 32, 31, 24, 24, 24, 21, 21,
14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3 };
for(unsigned int i = 0; i < sizeof(real)/sizeof(real[0]); i++)
{ counts[m] = real[i]; nameposs[m] = 1000 + i; m++; }
for(int i = 0; i < 150; i++) { counts[m] = (i % 2) + 1; nameposs[m] = 5000 + i; m++; }
printf("domain slots scanned: %u (38 phantom namepos==0 + %zu real + 150 tail)\n\n",
m, sizeof(real)/sizeof(real[0]));
get_top_domains_sim(counts, nameposs, m, 10);
get_top_domains_sim(counts, nameposs, m, 100);
printf("\nBUG: count=10 returns 2 rows although >=10 real domains exist.\n");
printf("count=100 works only because heap_cap >= slot count disables eviction.\n");
return 0;
}
Versions
Platform
Expected behavior
GET /api/stats/top_domains(defaultcount=10) should return the top 10domains -- the first 10 of the
?count=100list.Actual behavior / bug
The default request returns only 2 domains; all lower-count ones are
dropped.
?count=100on the same instance, same moment, returns the full,correctly sorted list. So the data is fine -- a small
countjust discards mostof it.
Steps to reproduce
Reproduction depends on SHM state, so I isolated the exact selection/output
logic from
src/api/stats.cinto a standalone C program (inlined below) -- norunning instance needed:
Standalone reproducer (
repro.c)Debug Token
Screenshots
N/A
Additional context
Regression from commit
8f8fd45b(v6.7), which madeget_top_domains()use abounded min-heap of capacity
count*4(40 by default) instead of an array overall domains.
The scan loop admits recycled/uninitialized slots (
domainpos == 0, whosegetstr()is"", notHIDDEN_DOMAIN) as long ascount-blockedcount >= 1.These are only skipped later, at output (
if(top_domains[i].namepos == 0)).With the old full-size array that was harmless; with the small heap they consume
capacity and evict real domains, so the response has far fewer than
countrows. Large
countgrows the heap past the slot count, disabling eviction --hence
?count=100works.Fix: skip these slots in the scan loop, before heap insertion:
This is exactly what the sibling
get_top_clients()already does -- its scanloop skips recycled slots up front (
if(client->ippos == 0) continue;), so itis not affected.
get_top_domains()looks like it simply missed the equivalentguard and only filters
namepos == 0at output.Investigation and write-up were AI-assisted, then reviewed and verified.