diff --git a/cmd/server/analytics_tie_order_256_test.go b/cmd/server/analytics_tie_order_256_test.go new file mode 100644 index 000000000..25d44967d --- /dev/null +++ b/cmd/server/analytics_tie_order_256_test.go @@ -0,0 +1,336 @@ +package main + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + "testing" + "time" +) + +// Issue #256: analytics lists are built by ranging over a map and were sorted +// on one key only. Go map iteration order is random, so entries with equal +// values came out in a different order on each recompute, and in lists capped +// at N it was random which tied entries made the cut. These tests compute each +// list many times and require the same, fully specified order every time. + +const tieOrderRuns = 50 + +// newTieOrderStore builds an in-memory store over packets with an empty node +// cache, so hops stay unresolved and no DB is needed. +func newTieOrderStore(packets []*StoreTx) *PacketStore { + s := newChannelTestStore(packets) + s.nodeCache = []nodeInfo{} + s.nodePM = buildPrefixMap(nil) + s.nodeCacheTime = time.Now() + return s +} + +// assertSameEveryRun calls compute tieOrderRuns times and fails unless every +// result equals want. +func assertSameEveryRun(t *testing.T, name string, want interface{}, compute func() interface{}) { + t.Helper() + for run := 0; run < tieOrderRuns; run++ { + if got := compute(); !reflect.DeepEqual(got, want) { + t.Errorf("%s, run %d:\n got %v\n want %v", name, run, got, want) + return + } + } +} + +// mapField projects one field of each entry of a []map[string]interface{}. +func mapField(list interface{}, field string) []string { + out := []string{} + for _, e := range list.([]map[string]interface{}) { + out = append(out, fmt.Sprint(e[field])) + } + return out +} + +func makeHashSizeAdvert(pubkey, name, firstSeen string) *StoreTx { + pt := PayloadADVERT + d, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey, "name": name}) + return &StoreTx{ + // Header 0x11: flood route, ADVERT. Path byte 0x41: 2-byte hashes, 1 hop. + RawHex: "1141aabbccdd", + Hash: "adv-" + pubkey + "-" + firstSeen, + FirstSeen: firstSeen, + PayloadType: &pt, + DecodedJSON: string(d), + } +} + +func TestHashSizesMultiByteNodesTieOrder_256(t *testing.T) { + // pubkey → adverts. Six nodes tie on 3, inserted in an order that is not + // the expected one. + counts := []struct { + pk string + n int + }{ + {"f6000000", 3}, {"a1000000", 3}, {"d4000000", 3}, {"0b000000", 5}, + {"c3000000", 3}, {"b2000000", 3}, {"e5000000", 3}, {"99000000", 1}, + } + var packets []*StoreTx + for _, c := range counts { + for i := 0; i < c.n; i++ { + packets = append(packets, makeHashSizeAdvert(c.pk, "N-"+c.pk, fmt.Sprintf("2026-05-01T12:%02d:00Z", i))) + } + } + s := newTieOrderStore(packets) + + want := []string{"0b000000", "a1000000", "b2000000", "c3000000", "d4000000", "e5000000", "f6000000", "99000000"} + assertSameEveryRun(t, "multiByteNodes pubkeys", want, func() interface{} { + return mapField(s.computeAnalyticsHashSizes("", "")["multiByteNodes"], "pubkey") + }) +} + +func TestHashSizesTopHopsTieOrder_256(t *testing.T) { + // 60 distinct 2-byte hops, each seen twice, plus one seen three times. + // The list is capped at 50, so the tie also decides which hops are listed. + var packets []*StoreTx + var hops []string + for i := 0; i < 60; i++ { + hops = append(hops, fmt.Sprintf("%04x", (i*37)%256+0x1000)) + } + add := func(hop string) { + pt := PayloadTXT_MSG + packets = append(packets, &StoreTx{ + RawHex: "0941aabb", Hash: fmt.Sprintf("h%d", len(packets)), + FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt, + PathJSON: `["` + hop + `"]`, + }) + } + for _, h := range hops { + add(h) + add(h) + } + add("ffff") + add("ffff") + add("ffff") + s := newTieOrderStore(packets) + + sorted := append([]string(nil), hops...) + sort.Strings(sorted) + want := append([]string{"ffff"}, sorted[:49]...) + assertSameEveryRun(t, "topHops hex", want, func() interface{} { + return mapField(s.computeAnalyticsHashSizes("", "")["topHops"], "hex") + }) +} + +func TestChannelAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + add := func(chHash int, channel, sender, firstSeen string) { + tx := makeGrpTxWithStatus(chHash, channel, "hi", sender, "decrypted") + tx.ID = len(packets) + 1 + tx.FirstSeen = firstSeen + packets = append(packets, tx) + } + // Six channels with 2 messages each, one with 4. Each message has its own + // sender (20 senders with 1 message, capped at 15), plus one sender with 3. + sender := 0 + nextSender := func() string { sender++; return fmt.Sprintf("s%02d", (sender*7)%23) } + for _, ch := range []int{60, 10, 50, 20, 40, 30} { + for i := 0; i < 2; i++ { + add(ch, fmt.Sprintf("#c%d", ch), nextSender(), "2026-05-01T12:00:00Z") + } + } + for i := 0; i < 4; i++ { + add(70, "#c70", "zz-top", "2026-05-01T13:00:00Z") + } + for i := 0; i < 8; i++ { + add(80, "#c80", nextSender(), "2026-05-01T14:00:00Z") + } + s := newTieOrderStore(packets) + s.byPayloadType[5] = packets + compute := func() map[string]interface{} { return s.computeAnalyticsChannels("", "", TimeWindow{}) } + + // channels: messages desc, then hash asc. + assertSameEveryRun(t, "channels hash", []string{"80", "70", "10", "20", "30", "40", "50", "60"}, func() interface{} { + return mapField(compute()["channels"], "hash") + }) + + // topSenders: count desc, then name asc, capped at 15. + var names []string + for k := range senderSet(packets) { + if k != "zz-top" { + names = append(names, k) + } + } + sort.Strings(names) + wantSenders := append([]string{"zz-top"}, names[:14]...) + assertSameEveryRun(t, "topSenders name", wantSenders, func() interface{} { + return mapField(compute()["topSenders"], "name") + }) + + // channelTimeline: hour asc, then channel asc. + wantTL := []string{ + "2026-05-01T12|#c10", "2026-05-01T12|#c20", "2026-05-01T12|#c30", + "2026-05-01T12|#c40", "2026-05-01T12|#c50", "2026-05-01T12|#c60", + "2026-05-01T13|#c70", "2026-05-01T14|#c80", + } + assertSameEveryRun(t, "channelTimeline hour|channel", wantTL, func() interface{} { + out := []string{} + for _, e := range compute()["channelTimeline"].([]map[string]interface{}) { + out = append(out, e["hour"].(string)+"|"+e["channel"].(string)) + } + return out + }) +} + +func senderSet(packets []*StoreTx) map[string]bool { + out := map[string]bool{} + for _, tx := range packets { + var d struct { + Sender string `json:"sender"` + } + json.Unmarshal([]byte(tx.DecodedJSON), &d) + out[d.Sender] = true + } + return out +} + +func TestRFAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + snr := 5.0 + // Payload types 0..6 each with 2 packets (one observation each), type 8 + // with 3. + add := func(ptype int) { + pt := ptype + id := len(packets) + 1 + packets = append(packets, &StoreTx{ + ID: id, Hash: fmt.Sprintf("rf%d", id), RawHex: "0000", + FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt, + Observations: []*StoreObs{{ID: id, ObserverID: "obs1", SNR: &snr, Timestamp: "2026-05-01T12:00:00Z"}}, + }) + } + for _, p := range []int{6, 2, 4, 0, 5, 1, 3} { + add(p) + add(p) + } + add(8) + add(8) + add(8) + s := newTieOrderStore(packets) + compute := func() map[string]interface{} { return s.computeAnalyticsRF("", "", TimeWindow{}) } + + assertSameEveryRun(t, "payloadTypes type", []int{8, 0, 1, 2, 3, 4, 5, 6}, func() interface{} { + v := reflect.ValueOf(compute()["payloadTypes"]) + out := []int{} + for i := 0; i < v.Len(); i++ { + out = append(out, int(v.Index(i).FieldByName("Type").Int())) + } + return out + }) + + // snrByType: count desc, then type name asc. + var tied []string + for _, p := range []int{0, 1, 2, 3, 4, 5, 6} { + tied = append(tied, payloadTypeNames[p]) + } + sort.Strings(tied) + wantSNR := append([]string{payloadTypeNames[8]}, tied...) + assertSameEveryRun(t, "snrByType name", wantSNR, func() interface{} { + v := reflect.ValueOf(compute()["snrByType"]) + out := []string{} + for i := 0; i < v.Len(); i++ { + out = append(out, v.Index(i).FieldByName("Name").String()) + } + return out + }) +} + +func TestTopologyAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + add := func(obsID, path string) { + id := len(packets) + 1 + packets = append(packets, &StoreTx{ + ID: id, Hash: fmt.Sprintf("tp%d", id), FirstSeen: "2026-05-01T12:00:00Z", + ObserverID: obsID, ObserverName: "Name-" + obsID, PathJSON: path, + }) + } + // Six observers each hear the same six 2-hop paths once, so every hop, + // every pair, every ring entry and every cross-observer entry ties. + observers := []string{"obs-f", "obs-a", "obs-d", "obs-c", "obs-e", "obs-b"} + paths := []string{`["f1","e1"]`, `["a1","b1"]`, `["d1","c1"]`} + for _, o := range observers { + for _, p := range paths { + add(o, p) + } + } + s := newTieOrderStore(packets) + compute := func() map[string]interface{} { return s.computeAnalyticsTopology("", "", TimeWindow{}) } + + assertSameEveryRun(t, "topRepeaters hop", []string{"a1", "b1", "c1", "d1", "e1", "f1"}, func() interface{} { + return mapField(compute()["topRepeaters"], "hop") + }) + assertSameEveryRun(t, "topPairs hopA|hopB", []string{"a1|b1", "c1|d1", "e1|f1"}, func() interface{} { + out := []string{} + for _, e := range compute()["topPairs"].([]map[string]interface{}) { + out = append(out, e["hopA"].(string)+"|"+e["hopB"].(string)) + } + return out + }) + // observers had no sort at all; the first one is the default tab. + assertSameEveryRun(t, "observers id", []string{"obs-a", "obs-b", "obs-c", "obs-d", "obs-e", "obs-f"}, func() interface{} { + return mapField(compute()["observers"], "id") + }) + assertSameEveryRun(t, "perObserverReach obs-a rings", []string{"1:b1,c1,e1", "2:a1,d1,f1"}, func() interface{} { + reach := compute()["perObserverReach"].(map[string]interface{})["obs-a"].(map[string]interface{}) + out := []string{} + for _, r := range reach["rings"].([]map[string]interface{}) { + out = append(out, fmt.Sprintf("%d:%s", r["hops"], strings.Join(mapField(r["nodes"], "hop"), ","))) + } + return out + }) + wantObs := strings.Join([]string{"obs-a", "obs-b", "obs-c", "obs-d", "obs-e", "obs-f"}, ",") + assertSameEveryRun(t, "multiObsNodes hop:observers", []string{ + "a1:" + wantObs, "b1:" + wantObs, "c1:" + wantObs, "d1:" + wantObs, "e1:" + wantObs, "f1:" + wantObs, + }, func() interface{} { + out := []string{} + for _, e := range compute()["multiObsNodes"].([]map[string]interface{}) { + out = append(out, e["hop"].(string)+":"+strings.Join(mapField(e["observers"], "observer_id"), ",")) + } + return out + }) + // bestPathList: minDist asc, then hop asc; among observers tied on + // minDist the lowest observer id is reported. + assertSameEveryRun(t, "bestPathList hop@observer", []string{ + "b1@obs-a", "c1@obs-a", "e1@obs-a", "a1@obs-a", "d1@obs-a", "f1@obs-a", + }, func() interface{} { + out := []string{} + for _, e := range compute()["bestPathList"].([]map[string]interface{}) { + out = append(out, e["hop"].(string)+"@"+e["observer_id"].(string)) + } + return out + }) +} + +func TestDistanceTopHopsTieOrder_256(t *testing.T) { + // Six pairs at exactly the same distance, one farther, one nearer. + var hops []distHopRecord + for _, p := range [][2]string{{"F", "f"}, {"A", "a"}, {"D", "d"}, {"C", "c"}, {"E", "e"}, {"B", "b"}} { + hops = append(hops, distHopRecord{FromPk: p[0], ToPk: p[1], Dist: 42, Hash: "h" + p[0]}) + } + hops = append(hops, distHopRecord{FromPk: "Z", ToPk: "z", Dist: 99, Hash: "hZ"}) + hops = append(hops, distHopRecord{FromPk: "Y", ToPk: "y", Dist: 1, Hash: "hY"}) + + // limit 5 also checks which tied pairs make the cut. + assertSameEveryRun(t, "distance topHops fromPk", []string{"Z", "A", "B", "C", "D"}, func() interface{} { + return mapField(dedupeHopsByPair(hops, 5), "fromPk") + }) +} + +func TestRankSubpathsTieOrder_256(t *testing.T) { + counts := map[string]*subpathAccum{} + for _, p := range []string{"F → G", "A → B", "D → E", "C → D", "E → F", "B → C"} { + counts[p] = &subpathAccum{count: 4, raw: strings.ToLower(strings.ReplaceAll(p, " → ", ","))} + } + counts["X → Y"] = &subpathAccum{count: 9, raw: "x,y"} + s := newTieOrderStore(nil) + + assertSameEveryRun(t, "subpaths path", []string{"X → Y", "A → B", "B → C", "C → D"}, func() interface{} { + return mapField(s.rankSubpaths(counts, 100, 4)["subpaths"], "path") + }) +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 55c7d38b1..df1e5f78c 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -677,7 +677,14 @@ func dedupeHopsByPair(hops []distHopRecord, limit int) []map[string]interface{} for k, v := range pairMap { pairs = append(pairs, pairEntry{k, v}) } - sort.Slice(pairs, func(i, j int) bool { return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist }) + // #256: pairMap iteration order is random; break distance ties on the + // pair key so the order (and which tied pairs make the limit) is stable. + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].agg.best.Dist != pairs[j].agg.best.Dist { + return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist + } + return pairs[i].key < pairs[j].key + }) result := make([]map[string]interface{}, 0, min(limit, len(pairs))) for i, pe := range pairs { if i >= limit { @@ -6768,8 +6775,14 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW "lastActivity": c.LastActivity, "encrypted": c.Encrypted, }) } + // #256: channelMap iteration order is random; ties break on the channel + // hash (the map key) so equal counts keep one order across recomputes. sort.Slice(channelList, func(i, j int) bool { - return channelList[i]["messages"].(int) > channelList[j]["messages"].(int) + mi, mj := channelList[i]["messages"].(int), channelList[j]["messages"].(int) + if mi != mj { + return mi > mj + } + return channelList[i]["hash"].(string) < channelList[j]["hash"].(string) }) // Top senders @@ -6781,7 +6794,12 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW for n, c := range senderCounts { senderList = append(senderList, senderEntry{n, c}) } - sort.Slice(senderList, func(i, j int) bool { return senderList[i].count > senderList[j].count }) + sort.Slice(senderList, func(i, j int) bool { + if senderList[i].count != senderList[j].count { + return senderList[i].count > senderList[j].count + } + return senderList[i].name < senderList[j].name + }) topSenders := make([]map[string]interface{}, 0) for i, e := range senderList { if i >= 15 { @@ -6802,7 +6820,12 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW tlList = append(tlList, tlEntry{parts[0], parts[1], count}) } } - sort.Slice(tlList, func(i, j int) bool { return tlList[i].hour < tlList[j].hour }) + sort.Slice(tlList, func(i, j int) bool { + if tlList[i].hour != tlList[j].hour { + return tlList[i].hour < tlList[j].hour + } + return tlList[i].channel < tlList[j].channel + }) channelTimeline := make([]map[string]interface{}, 0, len(tlList)) for _, e := range tlList { channelTimeline = append(channelTimeline, map[string]interface{}{ @@ -7239,7 +7262,13 @@ func (s *PacketStore) computeAnalyticsRF(region, area string, window TimeWindow) } payloadTypes = append(payloadTypes, ptEntry{Type: t, Name: name, Count: c}) } - sort.Slice(payloadTypes, func(i, j int) bool { return payloadTypes[i].Count > payloadTypes[j].Count }) + // #256: built from maps; ties break on the map key for a stable order. + sort.Slice(payloadTypes, func(i, j int) bool { + if payloadTypes[i].Count != payloadTypes[j].Count { + return payloadTypes[i].Count > payloadTypes[j].Count + } + return payloadTypes[i].Type < payloadTypes[j].Type + }) // SNR by type type snrTypeEntry struct { @@ -7261,7 +7290,12 @@ func (s *PacketStore) computeAnalyticsRF(region, area string, window TimeWindow) Min: minF64(d.vals), Max: maxF64(d.vals), }) } - sort.Slice(snrByTypeArr, func(i, j int) bool { return snrByTypeArr[i].Count > snrByTypeArr[j].Count }) + sort.Slice(snrByTypeArr, func(i, j int) bool { + if snrByTypeArr[i].Count != snrByTypeArr[j].Count { + return snrByTypeArr[i].Count > snrByTypeArr[j].Count + } + return snrByTypeArr[i].Name < snrByTypeArr[j].Name + }) // Signal over time type sigTimeEntry struct { @@ -8273,7 +8307,17 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW for h, c := range hopFreq { freqList = append(freqList, freqEntry{h, c}) } - sort.Slice(freqList, func(i, j int) bool { return freqList[i].count > freqList[j].count }) + // #256: hopFreq/pairFreq iteration order is random; ties break on the + // hop (pair) key so the order and the top-N cut are stable. + byCountThenHop := func(list []freqEntry) func(i, j int) bool { + return func(i, j int) bool { + if list[i].count != list[j].count { + return list[i].count > list[j].count + } + return list[i].hop < list[j].hop + } + } + sort.Slice(freqList, byCountThenHop(freqList)) topRepeaters := make([]map[string]interface{}, 0) for i, e := range freqList { if i >= 20 { @@ -8293,7 +8337,7 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW for p, c := range pairFreq { pairList = append(pairList, freqEntry{p, c}) } - sort.Slice(pairList, func(i, j int) bool { return pairList[i].count > pairList[j].count }) + sort.Slice(pairList, byCountThenHop(pairList)) topPairs := make([]map[string]interface{}, 0) for i, e := range pairList { if i >= 15 { @@ -8344,6 +8388,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW } observers = append(observers, map[string]interface{}{"id": id, "name": n}) } + // #256: built from a map with no sort; the first entry is the frontend's + // default observer tab, so give it a stable order. + sort.Slice(observers, func(i, j int) bool { + return observers[i]["id"].(string) < observers[j]["id"].(string) + }) // Per-observer reachability perObserverReach := map[string]interface{}{} @@ -8375,7 +8424,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW rings := make([]map[string]interface{}, 0) for dist, nodeList := range byDist { sort.Slice(nodeList, func(i, j int) bool { - return nodeList[i]["count"].(int) > nodeList[j]["count"].(int) + ci, cj := nodeList[i]["count"].(int), nodeList[j]["count"].(int) + if ci != cj { + return ci > cj + } + return nodeList[i]["hop"].(string) < nodeList[j]["hop"].(string) }) rings = append(rings, map[string]interface{}{"hops": dist, "nodes": nodeList}) } @@ -8401,7 +8454,10 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW "observer_id": obsID, "observer_name": obsName, "minDist": data.minDist, "count": data.count, }) - if bp, ok := bestPath[hop]; !ok || data.minDist < bp["minDist"].(int) { + // #256: perObserver iteration order is random; among observers + // tied on minDist report the lowest observer id. + if bp, ok := bestPath[hop]; !ok || data.minDist < bp["minDist"].(int) || + (data.minDist == bp["minDist"].(int) && obsID < bp["observer_id"].(string)) { bestPath[hop] = map[string]interface{}{ "minDist": data.minDist, "observer_id": obsID, "observer_name": obsName, } @@ -8416,7 +8472,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW continue } sort.Slice(obs, func(i, j int) bool { - return obs[i]["minDist"].(int) < obs[j]["minDist"].(int) + di, dj := obs[i]["minDist"].(int), obs[j]["minDist"].(int) + if di != dj { + return di < dj + } + return obs[i]["observer_id"].(string) < obs[j]["observer_id"].(string) }) r := resolveHop(hop) entry := map[string]interface{}{ @@ -8429,8 +8489,12 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW multiObsNodes = append(multiObsNodes, entry) } sort.Slice(multiObsNodes, func(i, j int) bool { - return len(multiObsNodes[i]["observers"].([]map[string]interface{})) > - len(multiObsNodes[j]["observers"].([]map[string]interface{})) + ni := len(multiObsNodes[i]["observers"].([]map[string]interface{})) + nj := len(multiObsNodes[j]["observers"].([]map[string]interface{})) + if ni != nj { + return ni > nj + } + return multiObsNodes[i]["hop"].(string) < multiObsNodes[j]["hop"].(string) }) if len(multiObsNodes) > 50 { multiObsNodes = multiObsNodes[:50] @@ -8452,7 +8516,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW bestPathList = append(bestPathList, entry) } sort.Slice(bestPathList, func(i, j int) bool { - return bestPathList[i]["minDist"].(int) < bestPathList[j]["minDist"].(int) + di, dj := bestPathList[i]["minDist"].(int), bestPathList[j]["minDist"].(int) + if di != dj { + return di < dj + } + return bestPathList[i]["hop"].(string) < bestPathList[j]["hop"].(string) }) if len(bestPathList) > 50 { bestPathList = bestPathList[:50] @@ -9155,8 +9223,14 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string] for hex, data := range uniqueHops { hopList = append(hopList, hopEntry{hex, data}) } + // #256: uniqueHops iteration order is random; ties break on the hop hex + // so the order and the top-50 cut are stable. sort.Slice(hopList, func(i, j int) bool { - return hopList[i].data["count"].(int) > hopList[j].data["count"].(int) + ci, cj := hopList[i].data["count"].(int), hopList[j].data["count"].(int) + if ci != cj { + return ci > cj + } + return hopList[i].hex < hopList[j].hex }) topHops := make([]map[string]interface{}, 0) for i, e := range hopList { @@ -9189,8 +9263,14 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string] }) } } + // #256: byNode iteration order is random; ties break on pubkey so the + // adopters table's default ("server order") is the same on every recompute. sort.Slice(multiByteNodes, func(i, j int) bool { - return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int) + pi, pj := multiByteNodes[i]["packets"].(int), multiByteNodes[j]["packets"].(int) + if pi != pj { + return pi > pj + } + return multiByteNodes[i]["pubkey"].(string) < multiByteNodes[j]["pubkey"].(string) }) // Distribution by repeaters: count unique REPEATER nodes per hash size @@ -11082,7 +11162,14 @@ func (s *PacketStore) rankSubpaths(counts map[string]*subpathAccum, totalPaths, for path, data := range counts { ranked = append(ranked, subpathEntry{path, data.count, data.raw}) } - sort.Slice(ranked, func(i, j int) bool { return ranked[i].count > ranked[j].count }) + // #256: counts iteration order is random; ties break on the path so the + // order and the limit cut are stable. + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].count != ranked[j].count { + return ranked[i].count > ranked[j].count + } + return ranked[i].path < ranked[j].path + }) if len(ranked) > limit { ranked = ranked[:limit] }