From 5b28d08e4720f890348ba56df51d885e9dfbbab9 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 21 Apr 2026 21:20:40 -0300 Subject: [PATCH] khatru: add tests and fix dispatcher. --- khatru/dispatcher_test.go | 261 ++++++++++++++++++ khatru/listener.go | 17 +- khatru/listener_fuzz_test.go | 6 +- khatru/listener_test.go | 24 +- khatru/relay_fuzz_test.go | 11 +- .../FuzzReplaceableEvents/00ff79377dab077d | 3 +- 6 files changed, 299 insertions(+), 23 deletions(-) create mode 100644 khatru/dispatcher_test.go diff --git a/khatru/dispatcher_test.go b/khatru/dispatcher_test.go new file mode 100644 index 0000000..df2848c --- /dev/null +++ b/khatru/dispatcher_test.go @@ -0,0 +1,261 @@ +package khatru + +import ( + "slices" + "strconv" + "testing" + + "fiatjaf.com/nostr" + "github.com/stretchr/testify/require" +) + +func TestDispatcherCandidates(t *testing.T) { + d := newDispatcher() + + d.addSubscription(subscription{ + id: "...", + filter: nostr.Filter{ + Kinds: []nostr.Kind{9}, + Tags: nostr.TagMap{"h": []string{"aaa"}}, + }, + }) + d.addSubscription(subscription{ + id: "...", + filter: nostr.Filter{ + Kinds: []nostr.Kind{11}, + Tags: nostr.TagMap{"h": []string{"aaa"}}, + }, + }) + d.addSubscription(subscription{ + id: "...", + filter: nostr.Filter{ + Kinds: []nostr.Kind{9, 11, 1111}, + Tags: nostr.TagMap{"h": []string{"aaa"}}, + }, + }) + d.addSubscription(subscription{ + id: "...", + filter: nostr.Filter{ + Kinds: []nostr.Kind{9, 11, 1111}, + Tags: nostr.TagMap{"h": []string{"bbb"}}, + }, + }) + d.addSubscription(subscription{ + id: "...", + filter: nostr.Filter{ + Kinds: []nostr.Kind{9, 11, 1111}, + Authors: []nostr.PubKey{ + nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"), + }, + }, + }) + + matched := 0 + for range d.candidates(nostr.Event{ + PubKey: nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"), + ID: nostr.MustIDFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"), + Kind: 9, + CreatedAt: nostr.Now(), + Content: "hello", + Tags: nostr.Tags{ + {"h", "aaa"}, + }, + }) { + matched++ + } + + require.Equal(t, 3, matched) +} + +func FuzzDispatcherCandidates(f *testing.F) { + f.Add(1, 1, uint8(8), uint8(16)) + f.Add(2, 3, uint8(32), uint8(32)) + + f.Fuzz(func(t *testing.T, seed int, advance int, ops uint8, checks uint8) { + d := newDispatcher() + state := fuzzState{value: seed, advance: advance} + + active := make(map[int]subscription) + activeSSIDs := make([]int, 0, int(ops)) + nextSubID := 0 + + steps := int(ops) + 1 + for range steps { + if len(activeSSIDs) == 0 || state.next(10) != 0 { + nextSubID++ + sub := subscription{ + id: strconv.Itoa(nextSubID), + filter: fuzzDispatcherFilter(&state), + } + + ssid := d.addSubscription(sub) + active[ssid] = sub + activeSSIDs = append(activeSSIDs, ssid) + } else { + idx := state.next(len(activeSSIDs)) + ssid := activeSSIDs[idx] + + d.removeSubscription(ssid) + delete(active, ssid) + activeSSIDs = append(activeSSIDs[:idx], activeSSIDs[idx+1:]...) + } + + for range int(checks%7) + 1 { + event := fuzzDispatcherEvent(&state) + expected := expectedDispatcherCandidates(active, event) + actual := collectedDispatcherCandidates(&d, event) + require.Equalf(t, expected, actual, "seed=%d advance=%d event=%s active=%v", seed, advance, event.String(), active) + } + } + + for _, ssid := range activeSSIDs { + d.removeSubscription(ssid) + delete(active, ssid) + } + + require.Empty(t, collectedDispatcherCandidates(&d, fuzzDispatcherEvent(&state))) + }) +} + +type fuzzState struct { + value int + advance int +} + +func (state *fuzzState) next(n int) int { + if n <= 0 { + return 0 + } + value := state.value % n + if value < 0 { + value += n + } + state.value += state.advance + return value +} + +func fuzzDispatcherFilter(seed *fuzzState) nostr.Filter { + filter := nostr.Filter{ + Authors: fuzzDispatcherAuthors(seed), + Kinds: fuzzDispatcherKinds(seed), + Tags: fuzzDispatcherTagMap(seed), + } + + if seed.next(3) == 0 { + since := nostr.Timestamp(seed.next(6)) + until := since + nostr.Timestamp(seed.next(6)) + filter.Since = since + filter.Until = until + } else if seed.next(4) == 0 { + filter.Since = nostr.Timestamp(seed.next(8)) + } else if seed.next(4) == 0 { + filter.Until = nostr.Timestamp(seed.next(8)) + } + + return filter +} + +func fuzzDispatcherAuthors(seed *fuzzState) []nostr.PubKey { + switch seed.next(4) { + case 0: + return nil + case 1: + return []nostr.PubKey{} + } + + count := seed.next(3) + 1 + authors := make([]nostr.PubKey, 0, count) + for range count { + pk := nostr.PubKey{byte(seed.next(4) + 1)} + if !slices.Contains(authors, pk) { + authors = append(authors, pk) + } + } + return authors +} + +func fuzzDispatcherKinds(seed *fuzzState) []nostr.Kind { + switch seed.next(4) { + case 0: + return nil + case 1: + return []nostr.Kind{} + } + + count := seed.next(3) + 1 + kinds := make([]nostr.Kind, 0, count) + for range count { + kind := nostr.Kind(seed.next(5) + 1) + if !slices.Contains(kinds, kind) { + kinds = append(kinds, kind) + } + } + return kinds +} + +func fuzzDispatcherTagMap(seed *fuzzState) nostr.TagMap { + if seed.next(3) == 0 { + return nil + } + + keys := []string{"e", "p", "t"} + values := []string{"a", "b", "c", "d"} + + count := seed.next(3) + if count == 0 { + return nostr.TagMap{} + } + + tags := make(nostr.TagMap, count) + start := seed.next(len(keys)) + for i := range count { + idx := (start + i) % len(keys) + valueCount := seed.next(3) + 1 + entries := make([]string, 0, valueCount) + for range valueCount { + value := values[seed.next(len(values))] + if !slices.Contains(entries, value) { + entries = append(entries, value) + } + } + tags[keys[idx]] = entries + } + + return tags +} + +func fuzzDispatcherEvent(seed *fuzzState) nostr.Event { + tags := make(nostr.Tags, 0, seed.next(4)) + keys := []string{"e", "p", "t"} + values := []string{"a", "b", "c", "d"} + for range cap(tags) { + tags = append(tags, nostr.Tag{keys[seed.next(len(keys))], values[seed.next(len(values))]}) + } + + return nostr.Event{ + PubKey: nostr.PubKey{byte(seed.next(4) + 1)}, + Kind: nostr.Kind(seed.next(5) + 1), + CreatedAt: nostr.Timestamp(seed.next(8)), + Tags: tags, + } +} + +func expectedDispatcherCandidates(active map[int]subscription, event nostr.Event) []string { + ids := make([]string, 0, len(active)) + for _, sub := range active { + if sub.filter.Matches(event) { + ids = append(ids, sub.id) + } + } + slices.Sort(ids) + return ids +} + +func collectedDispatcherCandidates(d *dispatcher, event nostr.Event) []string { + ids := make([]string, 0, d.subscriptions.Size()) + for sub := range d.candidates(event) { + ids = append(ids, sub.id) + } + slices.Sort(ids) + return ids +} diff --git a/khatru/listener.go b/khatru/listener.go index ad48f92..e6f6cfd 100644 --- a/khatru/listener.go +++ b/khatru/listener.go @@ -134,16 +134,27 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] { for _, ssid := range authorSubs.Slice() { sub, _ := d.subscriptions.Load(ssid) - if kindSubs.Has(ssid) { + if kindSubs.Has(ssid) || sub.filter.Kinds == nil { if filterMatchesTimestampConstraintsAndTags(sub.filter, event) { if !yield(sub) { return } } - } else { - // matched author but not tags, so this event doesn't qualify for any filter + } + } + + for _, ssid := range kindSubs.Slice() { + sub, _ := d.subscriptions.Load(ssid) + + if sub.filter.Authors != nil { continue } + + if filterMatchesTimestampConstraintsAndTags(sub.filter, event) { + if !yield(sub) { + return + } + } } } else if hasAuthorSubs { for _, ssid := range authorSubs.Slice() { diff --git a/khatru/listener_fuzz_test.go b/khatru/listener_fuzz_test.go index b6ff57d..b213c40 100644 --- a/khatru/listener_fuzz_test.go +++ b/khatru/listener_fuzz_test.go @@ -1,7 +1,6 @@ package khatru import ( - "math/rand" "testing" "fiatjaf.com/nostr" @@ -125,10 +124,7 @@ func FuzzRandomListenerIdRemoving(f *testing.F) { } require.Equal(t, len(subs)+extra, ssidCount) - rand.Shuffle(len(subs), func(i, j int) { - subs[i], subs[j] = subs[j], subs[i] - }) - for _, wsidToRemove := range subs { + for _, wsidToRemove := range moduloOrder(subs, int(utw+ubs+ualf+ualef)) { rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id) } diff --git a/khatru/listener_test.go b/khatru/listener_test.go index 758ba27..a3bcb49 100644 --- a/khatru/listener_test.go +++ b/khatru/listener_test.go @@ -1,7 +1,6 @@ package khatru import ( - "math/rand" "strings" "testing" @@ -23,6 +22,18 @@ func idFromSeq(seq int, min, max int) string { return result.String() } +func moduloOrder[T any](items []T, seed int) []T { + remaining := append([]T(nil), items...) + ordered := make([]T, 0, len(items)) + for len(remaining) > 0 { + idx := seed % len(remaining) + ordered = append(ordered, remaining[idx]) + remaining = append(remaining[:idx], remaining[idx+1:]...) + seed++ + } + return ordered +} + func TestListenerSetupAndRemoveOnce(t *testing.T) { rl := NewRelay() @@ -321,7 +332,7 @@ func TestRandomListenerClientRemoving(t *testing.T) { ws := websockets[i] w := idFromSeqUpper(i) - if rand.Intn(2) < 1 { + if (i+j)%2 == 0 { l++ rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel) } @@ -374,12 +385,12 @@ func TestRandomListenerIdRemoving(t *testing.T) { ws := websockets[i] w := idFromSeqUpper(i) - if rand.Intn(2) < 1 { + if (i+j)%2 == 0 { id := w + ":" + idFromSeqLower(j) rl.addListener(ws, id, f, cancel) subs = append(subs, wsid{ws, id}) - if rand.Intn(5) < 1 { + if (i+j)%5 == 0 { rl.addListener(ws, id, f, cancel) extra++ } @@ -394,10 +405,7 @@ func TestRandomListenerIdRemoving(t *testing.T) { } require.Equal(t, len(subs)+extra, ssidCount) - rand.Shuffle(len(subs), func(i, j int) { - subs[i], subs[j] = subs[j], subs[i] - }) - for _, wsidToRemove := range subs { + for _, wsidToRemove := range moduloOrder(subs, 20) { rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id) } diff --git a/khatru/relay_fuzz_test.go b/khatru/relay_fuzz_test.go index 4238419..dd23575 100644 --- a/khatru/relay_fuzz_test.go +++ b/khatru/relay_fuzz_test.go @@ -3,7 +3,6 @@ package khatru import ( "context" "math" - "math/rand/v2" "net/http/httptest" "testing" "time" @@ -14,13 +13,15 @@ import ( ) func FuzzReplaceableEvents(f *testing.F) { - f.Add(uint(1), uint(2)) + f.Add(1, 1, uint(2)) - f.Fuzz(func(t *testing.T, seed uint, nevents uint) { + f.Fuzz(func(t *testing.T, seed int, advance int, nevents uint) { if nevents == 0 { return } + state := fuzzState{value: seed, advance: advance} + relay := NewRelay() store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"} store.Init() @@ -67,12 +68,10 @@ func FuzzReplaceableEvents(f *testing.F) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - rnd := rand.New(rand.NewPCG(uint64(seed), 0)) - newest := nostr.Timestamp(0) for range nevents { evt := createEvent(sk1, 0, `{"name":"blblbl"}`, nil) - evt.CreatedAt = nostr.Timestamp(rnd.Int64() % math.MaxUint32) + evt.CreatedAt = nostr.Timestamp(state.next(math.MaxUint32)) evt.Sign(sk1) err = client1.Publish(ctx, evt) if err != nil { diff --git a/khatru/testdata/fuzz/FuzzReplaceableEvents/00ff79377dab077d b/khatru/testdata/fuzz/FuzzReplaceableEvents/00ff79377dab077d index 59ed458..0af9260 100644 --- a/khatru/testdata/fuzz/FuzzReplaceableEvents/00ff79377dab077d +++ b/khatru/testdata/fuzz/FuzzReplaceableEvents/00ff79377dab077d @@ -1,3 +1,4 @@ go test fuzz v1 -uint(25) +int(25) +int(1) uint(223)