Files
meshcore-analyzer/cmd/server/pathhop_eviction_1908_test.go
T
n30nex abad8a26b3 fix(liveness): require evidence from each observed relay frame (#2298)
Red commit: `46e3b082` ([regression
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40203680455)).

Fixes #2266 and #2267. Certain relay activity now requires an eligible
path from the observation that supplied it. A representative
transmission header, destination attribution or catalogue uniqueness
alone cannot supply that evidence. Later valid FLOOD evidence survives
observer/path deduplication.

The fix preserves valid own packets and excludes failed signatures,
planned DIRECT/TRACE paths, ambiguous/retired prefixes and missing or
mismatched headers. Firmware reference: meshcore-dev/MeshCore
`81dbae59`.

- One eligibility byte replaces an unused raw-frame field. StoreObs
remains 152 bytes in the protected 160-byte allocation class; no wire
frames are retained. Existing dedup maps gain one pointer per slot only
at 64+ observations.
- Processing is linear in retained paths, bounded by 64 wire path bytes.
No new SQL, schema, configuration or maps.
- Tests cover five load/ingest paths, late duplicates,
analytics/adaptive thresholds, positive controls and concurrent readers.
Review correction `4ad6c5ad` preserves lazy decoded caches while
retaining escaped/legacy signature rejection.
- Full server suite, focused/race checks and frontend checks pass.
Browser verified on http://127.0.0.1:18905: both upstream false-positive
cases disappear and a genuine later FLOOD is recognized.

Independent review findings are addressed. Local preflight checks
passed; external home-directory bundle unavailable. [Final
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40205015381)
passed on `4ad6c5ad`, including all three browser shards and both
architecture builds.
2026-10-26 15:42:01 +01:00

187 lines
6.6 KiB
Go

package main
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
)
func TestPathHopEviction1908(t *testing.T) {
for _, mode := range []string{"time-direct", "time-run", "memory-empty-batch", "memory-index-disabled"} {
t.Run(mode, func(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(8, now.Add(-30*time.Minute), 0)
store.byPayloadType[5] = store.byPayloadType[4]
delete(store.byPayloadType, 4)
store.byPathHop = make(map[string][]*StoreTx)
store.useResolvedPathIndex = mode != "memory-index-disabled"
store.initResolvedPathIndex()
store.retentionHours = 1
if strings.HasPrefix(mode, "memory-") {
store.maxMemoryMB = 1
store.trackedBytes = 8 * 1048576 // The 25% cap evicts the first two.
} else {
for _, tx := range store.packets[:2] {
tx.FirstSeen = now.Add(-2 * time.Hour).Format(time.RFC3339)
}
}
shared := strings.Repeat("a", 64)
expiredOnly := strings.Repeat("b", 64)
retainedOnly := strings.Repeat("c", 64)
expiredScope, retainedScope := "expired-scope", "retained-scope"
hopsSeen := make(map[string]bool)
for i, tx := range store.packets {
*tx.PayloadType = 5 // Non-advert traffic contributes to relay stats.
pk := retainedOnly
tx.ScopeName = &retainedScope
if i < 2 {
pk = expiredOnly
tx.ScopeName = &expiredScope
}
addTxToPathHopIndex(store.byPathHop, tx)
store.indexResolvedPathHops(tx, []string{shared, pk}, hopsSeen, nil, nil)
}
// Repeated observations no longer append the same resolved
// association again (#2108), but eviction must still drop every
// occurrence from a bucket that holds one, so seed duplicates directly.
store.byPathHop[shared] = append(store.byPathHop[shared], store.packets[0], store.packets[7])
store.byPathHop[expiredOnly] = append(store.byPathHop[expiredOnly], store.packets[0])
addTxToPathHopIndex(store.byPathHop, store.packets[0])
// Keep the original backing arrays observable: a shortened slice must
// not hide pointers that keep evicted transmissions alive for the GC.
backingArrays := make(map[string][]*StoreTx, len(store.byPathHop))
for key, list := range store.byPathHop {
backingArrays[key] = list[:cap(list)]
}
before := store.GetRepeaterNodeStatsBatchCached([]string{shared}, 24)[shared].Info
if before.RelayCount24h != 8 || len(before.TransportedScopes) != 2 {
t.Fatalf("fixture must index all transmissions and scopes: %+v", before)
}
var evicted int
if mode == "time-run" {
evicted = store.RunEviction() // No DB: cleanup cannot depend on SQL.
} else {
store.mu.Lock()
if strings.HasPrefix(mode, "memory-") {
evicted = store.EvictStaleWithRP(map[int][]string{})
} else {
evicted = store.EvictStale()
}
store.mu.Unlock()
}
if evicted != 2 {
t.Fatalf("evicted %d transmissions, want 2", evicted)
}
expected := map[string][]int{
"aa": {3, 4, 5, 6, 7, 8},
"bb": {3, 4, 5, 6, 7, 8},
"cc": {3, 4, 5, 6, 7, 8},
shared: {3, 4, 5, 6, 7, 8, 8},
retainedOnly: {3, 4, 5, 6, 7, 8},
}
if len(store.byPathHop) != len(expected) {
t.Errorf("path-hop index has %d keys, want %d (expired-only keys must disappear)", len(store.byPathHop), len(expected))
}
for key, want := range expected {
var ids []int
for _, tx := range store.byPathHop[key] {
ids = append(ids, tx.ID)
}
if !reflect.DeepEqual(ids, want) {
t.Errorf("path-hop bucket %q = %v, want %v", key, ids, want)
}
}
for key, list := range backingArrays {
for _, tx := range list {
if tx != nil && tx.ID <= 2 {
t.Errorf("backing array for %q still retains evicted tx %d", key, tx.ID)
break
}
}
}
if store.relayStatsCache != nil {
t.Error("eviction must invalidate the relay-stats cache")
}
after := store.GetRepeaterNodeStatsBatchCached([]string{shared}, 24)[shared].Info
if after.RelayCount24h != 6 {
t.Errorf("relay count after eviction = %d, want 6", after.RelayCount24h)
}
if !reflect.DeepEqual(after.TransportedScopes, []string{retainedScope}) {
t.Errorf("transported scopes after eviction = %v, want only retained scope", after.TransportedScopes)
}
// A later sweep that removes every survivor must also remove every key.
for _, tx := range store.packets {
tx.FirstSeen = now.Add(-2 * time.Hour).Format(time.RFC3339)
}
if got := store.RunEviction(); got != 6 {
t.Fatalf("final eviction removed %d transmissions, want 6", got)
}
if len(store.byPathHop) != 0 {
t.Errorf("empty store retains %d path-hop keys", len(store.byPathHop))
}
})
}
}
// Benchmark the eviction path with 8 raw and 8 resolved hops per transmission,
// 2 observations each, and 2,048 resolved relays sharing 256 raw-prefix buckets.
// Other secondary indexes are omitted to isolate path-hop cleanup cost. Setup
// is outside the timer; the timed operation includes acquiring the store lock.
func BenchmarkPathHopEviction1908(b *testing.B) {
const hops = 8
pubkeys := make([]string, 2048)
for i := range pubkeys {
pubkeys[i] = fmt.Sprintf("%02x%062x", i%256, i)
}
for _, count := range []int{30000, 100000} {
for _, evictCount := range []int{1, count / 10, count / 4} {
b.Run(fmt.Sprintf("tx=%d/evict=%d", count, evictCount), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
b.StopTimer()
now := time.Now().UTC()
old := now.Add(-2 * time.Hour).Format(time.RFC3339)
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
store := &PacketStore{
packets: make([]*StoreTx, count),
byPathHop: make(map[string][]*StoreTx, len(pubkeys)+256),
retentionHours: 1,
}
hopsSeen := make(map[string]bool, hops*2)
for j := range store.packets {
tx := &StoreTx{ID: j + 1, FirstSeen: recent, parsedPath: make([]string, hops), pathParsed: true}
if j < evictCount {
tx.FirstSeen = old
}
tx.Observations = []*StoreObs{{ID: j * 2}, {ID: j*2 + 1}}
resolved := make([]string, hops)
for k := range resolved {
resolved[k] = pubkeys[(j*17+k*13)%len(pubkeys)]
tx.parsedPath[k] = resolved[k][:2]
}
store.packets[j] = tx
addTxToPathHopIndex(store.byPathHop, tx)
store.addResolvedPubkeysToPathHopIndex(tx, resolved, hopsSeen)
}
b.StartTimer()
store.mu.Lock()
got := store.EvictStale()
store.mu.Unlock()
b.StopTimer()
if got != evictCount {
b.Fatalf("evicted %d transmissions, want %d", got, evictCount)
}
}
b.ReportMetric(float64(count*hops*2), "entries/batch")
})
}
}
}