Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions cmd/server/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -11299,8 +11299,13 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
for path, count := range parentPaths {
topParents = append(topParents, map[string]interface{}{"path": path, "count": count})
}
// #273: parentPaths iteration order is random; ties break on the path so
// the order and the top-15 cut are stable (sibling of rankSubpaths/#256).
sort.Slice(topParents, func(i, j int) bool {
return topParents[i]["count"].(int) > topParents[j]["count"].(int)
if topParents[i]["count"].(int) != topParents[j]["count"].(int) {
return topParents[i]["count"].(int) > topParents[j]["count"].(int)
}
return topParents[i]["path"].(string) < topParents[j]["path"].(string)
})
if len(topParents) > 15 {
topParents = topParents[:15]
Expand All @@ -11310,8 +11315,13 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
for name, count := range observers {
topObs = append(topObs, map[string]interface{}{"name": name, "count": count})
}
// #273: observers iteration order is random; ties break on the name so
// the order and the top-10 cut are stable.
sort.Slice(topObs, func(i, j int) bool {
return topObs[i]["count"].(int) > topObs[j]["count"].(int)
if topObs[i]["count"].(int) != topObs[j]["count"].(int) {
return topObs[i]["count"].(int) > topObs[j]["count"].(int)
}
return topObs[i]["name"].(string) < topObs[j]["name"].(string)
})
if len(topObs) > 10 {
topObs = topObs[:10]
Expand Down
97 changes: 97 additions & 0 deletions cmd/server/subpath_detail_tie_order_273_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"fmt"
"sort"
"testing"
)

// Issue #273: GetSubpathDetail is a sibling of the #256-fixed rankSubpaths.
// Its parentPaths and observers lists are built by ranging over a map and were
// sorted on the count only. Go map iteration order is random, so entries with
// equal counts came out in a different order on each call, and in lists capped
// at N it was random which tied entries made the cut. These tests compute the
// detail many times and require the same, fully specified order every time.

// newSubpathDetailStore builds a tie-order store and feeds every packet through
// the same subpath index that production uses, so GetSubpathDetail resolves its
// matched transmissions exactly as it would at runtime.
func newSubpathDetailStore(packets []*StoreTx) *PacketStore {
s := newTieOrderStore(packets)
for _, tx := range packets {
addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx)
}
return s
}

func TestSubpathDetailParentPathsTieOrder_273(t *testing.T) {
// Query the 2-hop subpath "a1 → b1". Each parent path extends it with a
// distinct third hop. 18 parent paths tie on 2, one is seen 5 times. The
// list is capped at 15, so the tie also decides which parents are listed.
rawHops := []string{"a1", "b1"}
var packets []*StoreTx
id := 0
add := func(path string) {
id++
pt := PayloadTXT_MSG
packets = append(packets, &StoreTx{
RawHex: "0941aabb", Hash: fmt.Sprintf("pp%d", id),
FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt,
ObserverName: "obs", PathJSON: path,
})
}
var tiedPaths []string
for i := 0; i < 18; i++ {
suffix := fmt.Sprintf("%04x", (i*37)%256+0x2000)
add(fmt.Sprintf(`["a1","b1","%s"]`, suffix))
add(fmt.Sprintf(`["a1","b1","%s"]`, suffix))
tiedPaths = append(tiedPaths, "a1 → b1 → "+suffix)
}
const hiSuffix = "ffff"
for i := 0; i < 5; i++ {
add(fmt.Sprintf(`["a1","b1","%s"]`, hiSuffix))
}
s := newSubpathDetailStore(packets)

sort.Strings(tiedPaths)
want := append([]string{"a1 → b1 → " + hiSuffix}, tiedPaths[:14]...)
assertSameEveryRun(t, "parentPaths path", want, func() interface{} {
return mapField(s.GetSubpathDetail(rawHops)["parentPaths"], "path")
})
}

func TestSubpathDetailObserversTieOrder_273(t *testing.T) {
// Every transmission carries the same 2-hop path "c1 → d1" but a distinct
// observer. 14 observers tie on 2, one is seen 5 times. The observers list
// is capped at 10, so the tie also decides which observers are listed.
rawHops := []string{"c1", "d1"}
var packets []*StoreTx
id := 0
add := func(observer string) {
id++
pt := PayloadTXT_MSG
packets = append(packets, &StoreTx{
RawHex: "0941aabb", Hash: fmt.Sprintf("ob%d", id),
FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt,
ObserverName: observer, PathJSON: `["c1","d1"]`,
})
}
var tiedObs []string
for i := 0; i < 14; i++ {
name := fmt.Sprintf("obs-%04x", (i*37)%256+0x3000)
add(name)
add(name)
tiedObs = append(tiedObs, name)
}
const hiObs = "obs-zzzz"
for i := 0; i < 5; i++ {
add(hiObs)
}
s := newSubpathDetailStore(packets)

sort.Strings(tiedObs)
want := append([]string{hiObs}, tiedObs[:9]...)
assertSameEveryRun(t, "observers name", want, func() interface{} {
return mapField(s.GetSubpathDetail(rawHops)["observers"], "name")
})
}
Loading