|
|
@@ -0,0 +1,466 @@
|
|
|
+package fragment
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "math/rand/v2"
|
|
|
+ "sync"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/slackhq/nebula/header"
|
|
|
+ "github.com/stretchr/testify/assert"
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
+)
|
|
|
+
|
|
|
+const testSubtype = header.MessageSubType(9)
|
|
|
+
|
|
|
+type testKey struct {
|
|
|
+ src string
|
|
|
+ fragID uint32
|
|
|
+}
|
|
|
+
|
|
|
+func randMsg(r *rand.Rand, n int) []byte {
|
|
|
+ b := make([]byte, n)
|
|
|
+ for i := range b {
|
|
|
+ b[i] = byte(r.Uint32())
|
|
|
+ }
|
|
|
+ return b
|
|
|
+}
|
|
|
+
|
|
|
+func testConfig() TableConfig {
|
|
|
+ return TableConfig{
|
|
|
+ MaxBytes: 1 << 20,
|
|
|
+ MaxEntries: 64,
|
|
|
+ MaxPerSource: 4,
|
|
|
+ MaxMsgLen: 16384,
|
|
|
+ TTL: 2 * time.Second,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// checkInvariants verifies the table's bookkeeping against its contents.
|
|
|
+func checkInvariants[K, S comparable](t *testing.T, tb *Table[K, S]) {
|
|
|
+ t.Helper()
|
|
|
+ tb.mu.Lock()
|
|
|
+ defer tb.mu.Unlock()
|
|
|
+
|
|
|
+ require.Len(t, tb.order, len(tb.entries))
|
|
|
+ require.LessOrEqual(t, len(tb.entries), tb.cfg.MaxEntries)
|
|
|
+ bytes := 0
|
|
|
+ perSource := map[S]int{}
|
|
|
+ for i, key := range tb.order {
|
|
|
+ e, ok := tb.entries[key]
|
|
|
+ require.True(t, ok)
|
|
|
+ require.Equal(t, i, e.pos)
|
|
|
+ bytes += e.a.Stored()
|
|
|
+ perSource[e.source]++
|
|
|
+ }
|
|
|
+ require.Equal(t, bytes, tb.bytes)
|
|
|
+ require.LessOrEqual(t, tb.bytes, tb.cfg.MaxBytes)
|
|
|
+ require.Equal(t, perSource, tb.perSource)
|
|
|
+ for _, n := range perSource {
|
|
|
+ require.LessOrEqual(t, n, tb.cfg.MaxPerSource)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func parse(t *testing.T, p []byte) (header.H, Header, []byte) {
|
|
|
+ t.Helper()
|
|
|
+ var nh header.H
|
|
|
+ require.NoError(t, nh.Parse(p))
|
|
|
+ h, sym, err := Parse(p[header.Len:])
|
|
|
+ require.NoError(t, err)
|
|
|
+ return nh, h, sym
|
|
|
+}
|
|
|
+
|
|
|
+func TestParse(t *testing.T) {
|
|
|
+ h := Header{FragID: 0xdeadbeef, MsgLen: 3000, Index: 7}
|
|
|
+ b := h.Encode(nil)
|
|
|
+ require.Len(t, b, HeaderLen)
|
|
|
+ b = append(b, make([]byte, 1000)...)
|
|
|
+
|
|
|
+ got, sym, err := Parse(b)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, h, got)
|
|
|
+ assert.Len(t, sym, 1000)
|
|
|
+
|
|
|
+ _, _, err = Parse(b[:HeaderLen-1])
|
|
|
+ require.ErrorIs(t, err, ErrShortFragment)
|
|
|
+ _, _, err = Parse(b[:len(b)-1])
|
|
|
+ require.ErrorIs(t, err, ErrSymbolSize)
|
|
|
+ _, _, err = Parse(append(b, 0))
|
|
|
+ require.ErrorIs(t, err, ErrSymbolSize)
|
|
|
+
|
|
|
+ bad := Header{FragID: 0, MsgLen: 3000}.Encode(nil)
|
|
|
+ _, _, err = Parse(append(bad, make([]byte, 1000)...))
|
|
|
+ require.ErrorIs(t, err, ErrFragID)
|
|
|
+
|
|
|
+ bad = Header{FragID: 1, MsgLen: 0}.Encode(nil)
|
|
|
+ _, _, err = Parse(bad)
|
|
|
+ require.ErrorIs(t, err, ErrMsgLen)
|
|
|
+
|
|
|
+ bad = Header{FragID: 1, MsgLen: 3000, Flags: 1}.Encode(nil)
|
|
|
+ _, _, err = Parse(append(bad, make([]byte, 1000)...))
|
|
|
+ require.ErrorIs(t, err, ErrFlags)
|
|
|
+}
|
|
|
+
|
|
|
+func TestDefaultRepair(t *testing.T) {
|
|
|
+ for k, want := range map[int]int{1: 2, 2: 2, 6: 2, 7: 3, 9: 3, 10: 4, 14: 5} {
|
|
|
+ assert.Equal(t, want, DefaultRepair(k), "k=%d", k)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestSender(t *testing.T) {
|
|
|
+ _, err := NewSender(nil, testSubtype, 0, 1, 1)
|
|
|
+ require.ErrorIs(t, err, ErrMsgLen)
|
|
|
+ _, err = NewSender(make([]byte, 65536), testSubtype, 0, 1, 1)
|
|
|
+ require.ErrorIs(t, err, ErrMsgLen)
|
|
|
+ _, err = NewSender(make([]byte, 10), testSubtype, 0, 1, 0)
|
|
|
+ require.ErrorIs(t, err, ErrFragID)
|
|
|
+
|
|
|
+ msg := randMsg(rand.New(rand.NewPCG(1, 1)), 9800)
|
|
|
+ s, err := NewSender(msg, testSubtype, 77, 2, 1234)
|
|
|
+ require.NoError(t, err)
|
|
|
+ require.Equal(t, 9, s.Layout().K)
|
|
|
+
|
|
|
+ indexes := func(ps [][]byte) []int {
|
|
|
+ var out []int
|
|
|
+ for _, p := range ps {
|
|
|
+ require.LessOrEqual(t, len(p), MaxPacketLen)
|
|
|
+ nh, h, _ := parse(t, p)
|
|
|
+ assert.Equal(t, header.Handshake, nh.Type)
|
|
|
+ assert.Equal(t, testSubtype, nh.Subtype)
|
|
|
+ assert.Equal(t, uint32(77), nh.RemoteIndex)
|
|
|
+ assert.Equal(t, uint64(2), nh.MessageCounter)
|
|
|
+ assert.Equal(t, uint32(1234), h.FragID)
|
|
|
+ assert.Equal(t, uint16(9800), h.MsgLen)
|
|
|
+ out = append(out, int(h.Index))
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ now, later, err := s.Attempt(3)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}, indexes(now))
|
|
|
+ assert.Equal(t, []int{9, 10, 11}, indexes(later))
|
|
|
+
|
|
|
+ // A retry offers only indexes the receiver has not seen.
|
|
|
+ now, later, err = s.Attempt(3)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, []int{12, 13, 14, 15, 16, 17, 18, 19, 20}, indexes(now))
|
|
|
+ assert.Equal(t, []int{21, 22, 23}, indexes(later))
|
|
|
+
|
|
|
+ // A sender rebuilt from the same message resumes where it was told to.
|
|
|
+ s2, err := NewSender(msg, testSubtype, 77, 2, 1234)
|
|
|
+ require.NoError(t, err)
|
|
|
+ s2.SetNext(s.Next())
|
|
|
+ now2, _, err := s2.Attempt(0)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, []int{24, 25, 26, 27, 28, 29, 30, 31, 32}, indexes(now2))
|
|
|
+ s2.SetNext(-1)
|
|
|
+ assert.Equal(t, 255, s2.Next())
|
|
|
+
|
|
|
+ // Indexes wrap once the space is used up.
|
|
|
+ for range 19 {
|
|
|
+ _, _, err = s.Attempt(3)
|
|
|
+ require.NoError(t, err)
|
|
|
+ }
|
|
|
+ now, _, err = s.Attempt(3)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, []int{252, 253, 254, 255, 0, 1, 2, 3, 4}, indexes(now))
|
|
|
+}
|
|
|
+
|
|
|
+// TestLossy runs whole messages through heavy loss, reordering and duplication,
|
|
|
+// merging across attempts, and checks the table ends empty.
|
|
|
+func TestLossy(t *testing.T) {
|
|
|
+ r := rand.New(rand.NewPCG(2, 2))
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ now := time.Unix(1000, 0)
|
|
|
+
|
|
|
+ for i := range 300 {
|
|
|
+ msg := randMsg(r, 1+r.IntN(16384))
|
|
|
+ fragID := uint32(i + 1)
|
|
|
+ s, err := NewSender(msg, testSubtype, 0, 1, fragID)
|
|
|
+ require.NoError(t, err)
|
|
|
+
|
|
|
+ var got *Result
|
|
|
+ for attempt := 0; got == nil; attempt++ {
|
|
|
+ require.Less(t, attempt, 20, "message never completed")
|
|
|
+ a, b, err := s.Attempt(DefaultRepair(s.Layout().K))
|
|
|
+ require.NoError(t, err)
|
|
|
+ var pkts [][]byte
|
|
|
+ for _, p := range append(a, b...) {
|
|
|
+ if r.Float64() < 0.3 {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ pkts = append(pkts, p)
|
|
|
+ if r.Float64() < 0.2 {
|
|
|
+ pkts = append(pkts, p)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ r.Shuffle(len(pkts), func(i, j int) { pkts[i], pkts[j] = pkts[j], pkts[i] })
|
|
|
+
|
|
|
+ for _, p := range pkts {
|
|
|
+ _, h, sym := parse(t, p)
|
|
|
+ res, err := tb.Add(testKey{"a", h.FragID}, "a", now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ if res != nil {
|
|
|
+ // The table keeps no memory of completed messages, so a late
|
|
|
+ // duplicate can complete one again; the handshake layer
|
|
|
+ // recognises the repeat.
|
|
|
+ require.True(t, bytes.Equal(msg, res.Msg))
|
|
|
+ got = res
|
|
|
+ }
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ }
|
|
|
+ now = now.Add(100 * time.Millisecond)
|
|
|
+ }
|
|
|
+ require.Positive(t, got.Received)
|
|
|
+ // Late fragments of a completed message start a fresh entry; drop it.
|
|
|
+ tb.Expire(now.Add(time.Hour))
|
|
|
+ }
|
|
|
+ assert.Zero(t, tb.Len())
|
|
|
+ assert.Zero(t, tb.Bytes())
|
|
|
+}
|
|
|
+
|
|
|
+// TestConcurrent spreads each message's fragments across goroutines, as
|
|
|
+// multiple UDP reader routines would.
|
|
|
+func TestConcurrent(t *testing.T) {
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ now := time.Unix(1000, 0)
|
|
|
+ r := rand.New(rand.NewPCG(3, 3))
|
|
|
+
|
|
|
+ const msgs = 50
|
|
|
+ want := make(map[uint32][]byte, msgs)
|
|
|
+ var pkts [][]byte
|
|
|
+ for id := uint32(1); id <= msgs; id++ {
|
|
|
+ msg := randMsg(r, 1+r.IntN(16384))
|
|
|
+ want[id] = msg
|
|
|
+ s, err := NewSender(msg, testSubtype, 0, 1, id)
|
|
|
+ require.NoError(t, err)
|
|
|
+ a, b, err := s.Attempt(DefaultRepair(s.Layout().K))
|
|
|
+ require.NoError(t, err)
|
|
|
+ pkts = append(pkts, a...)
|
|
|
+ pkts = append(pkts, b...)
|
|
|
+ }
|
|
|
+ r.Shuffle(len(pkts), func(i, j int) { pkts[i], pkts[j] = pkts[j], pkts[i] })
|
|
|
+
|
|
|
+ var mu sync.Mutex
|
|
|
+ done := map[uint32]bool{}
|
|
|
+ var wg sync.WaitGroup
|
|
|
+ for w := range 8 {
|
|
|
+ wg.Go(func() {
|
|
|
+ for i := w; i < len(pkts); i += 8 {
|
|
|
+ h, sym, err := Parse(pkts[i][16:])
|
|
|
+ if err != nil {
|
|
|
+ t.Error(err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ src := string(rune('a' + h.FragID))
|
|
|
+ res, err := tb.Add(testKey{src, h.FragID}, src, now, h, sym)
|
|
|
+ if err != nil {
|
|
|
+ t.Error(err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if res != nil {
|
|
|
+ if !bytes.Equal(want[h.FragID], res.Msg) {
|
|
|
+ t.Errorf("message %d decoded wrong", h.FragID)
|
|
|
+ }
|
|
|
+ mu.Lock()
|
|
|
+ done[h.FragID] = true
|
|
|
+ mu.Unlock()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ wg.Wait()
|
|
|
+ assert.Len(t, done, msgs)
|
|
|
+ checkInvariants(t, tb)
|
|
|
+}
|
|
|
+
|
|
|
+func firstFragment(t *testing.T, msgLen int, fragID uint32) (Header, []byte) {
|
|
|
+ t.Helper()
|
|
|
+ s, err := NewSender(make([]byte, msgLen), testSubtype, 0, 1, fragID)
|
|
|
+ require.NoError(t, err)
|
|
|
+ now, _, err := s.Attempt(0)
|
|
|
+ require.NoError(t, err)
|
|
|
+ _, h, sym := parse(t, now[0])
|
|
|
+ return h, sym
|
|
|
+}
|
|
|
+
|
|
|
+func TestTableLimits(t *testing.T) {
|
|
|
+ now := time.Unix(1000, 0)
|
|
|
+
|
|
|
+ t.Run("max message length", func(t *testing.T) {
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ h, sym := firstFragment(t, 16385, 1)
|
|
|
+ _, err := tb.Add(testKey{"a", 1}, "a", now, h, sym)
|
|
|
+ require.ErrorIs(t, err, ErrTooLarge)
|
|
|
+ assert.Zero(t, tb.Len())
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("per source quota", func(t *testing.T) {
|
|
|
+ cfg := testConfig()
|
|
|
+ cfg.MaxPerSource = 2
|
|
|
+ tb := NewTable[testKey, string](cfg)
|
|
|
+ for id := uint32(1); id <= 3; id++ {
|
|
|
+ h, sym := firstFragment(t, 5000, id)
|
|
|
+ _, err := tb.Add(testKey{"a", id}, "a", now, h, sym)
|
|
|
+ if id <= 2 {
|
|
|
+ require.NoError(t, err)
|
|
|
+ } else {
|
|
|
+ require.ErrorIs(t, err, ErrSourceQuota)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ h, sym := firstFragment(t, 5000, 4)
|
|
|
+ _, err := tb.Add(testKey{"b", 4}, "b", now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, 3, tb.Len())
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("max entries evicts", func(t *testing.T) {
|
|
|
+ cfg := testConfig()
|
|
|
+ cfg.MaxEntries = 4
|
|
|
+ tb := NewTable[testKey, string](cfg)
|
|
|
+ for id := uint32(1); id <= 10; id++ {
|
|
|
+ h, sym := firstFragment(t, 5000, id)
|
|
|
+ src := string(rune('a' + id))
|
|
|
+ _, err := tb.Add(testKey{src, id}, src, now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ }
|
|
|
+ assert.Equal(t, 4, tb.Len())
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("max bytes evicts others", func(t *testing.T) {
|
|
|
+ cfg := testConfig()
|
|
|
+ cfg.MaxBytes = 3 * 1000
|
|
|
+ tb := NewTable[testKey, string](cfg)
|
|
|
+ for id := uint32(1); id <= 10; id++ {
|
|
|
+ h, sym := firstFragment(t, 5000, id) // 5 symbols of 1000
|
|
|
+ src := string(rune('a' + id))
|
|
|
+ _, err := tb.Add(testKey{src, id}, src, now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ }
|
|
|
+ assert.Equal(t, 3000, tb.Bytes())
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("no room removes new entry", func(t *testing.T) {
|
|
|
+ cfg := testConfig()
|
|
|
+ cfg.MaxBytes = 500
|
|
|
+ tb := NewTable[testKey, string](cfg)
|
|
|
+ h, sym := firstFragment(t, 5000, 1)
|
|
|
+ _, err := tb.Add(testKey{"a", 1}, "a", now, h, sym)
|
|
|
+ require.ErrorIs(t, err, ErrNoRoom)
|
|
|
+ assert.Zero(t, tb.Len())
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("duplicates are not charged", func(t *testing.T) {
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ h, sym := firstFragment(t, 5000, 1)
|
|
|
+ for range 5 {
|
|
|
+ _, err := tb.Add(testKey{"a", 1}, "a", now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ }
|
|
|
+ assert.Equal(t, 1000, tb.Bytes())
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("mismatch drops the entry", func(t *testing.T) {
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ h, sym := firstFragment(t, 5000, 1)
|
|
|
+ _, err := tb.Add(testKey{"a", 1}, "a", now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+
|
|
|
+ h2, sym2 := firstFragment(t, 6000, 1)
|
|
|
+ _, err = tb.Add(testKey{"a", 1}, "a", now, h2, sym2)
|
|
|
+ require.ErrorIs(t, err, ErrMismatch)
|
|
|
+ assert.Zero(t, tb.Len())
|
|
|
+ assert.Zero(t, tb.Bytes())
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("ttl", func(t *testing.T) {
|
|
|
+ tb := NewTable[testKey, string](testConfig())
|
|
|
+ s, err := NewSender(make([]byte, 5000), testSubtype, 0, 1, 1)
|
|
|
+ require.NoError(t, err)
|
|
|
+ pkts, _, err := s.Attempt(0)
|
|
|
+ require.NoError(t, err)
|
|
|
+
|
|
|
+ _, h, sym := parse(t, pkts[0])
|
|
|
+ _, err = tb.Add(testKey{"a", 1}, "a", now, h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+
|
|
|
+ // Activity within the TTL keeps the entry and slides its deadline.
|
|
|
+ _, h, sym = parse(t, pkts[1])
|
|
|
+ _, err = tb.Add(testKey{"a", 1}, "a", now.Add(1500*time.Millisecond), h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, 2000, tb.Bytes())
|
|
|
+
|
|
|
+ tb.Expire(now.Add(3 * time.Second))
|
|
|
+ assert.Equal(t, 1, tb.Len())
|
|
|
+
|
|
|
+ // A stale entry is restarted rather than merged.
|
|
|
+ _, h, sym = parse(t, pkts[2])
|
|
|
+ _, err = tb.Add(testKey{"a", 1}, "a", now.Add(10*time.Second), h, sym)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, 1000, tb.Bytes())
|
|
|
+
|
|
|
+ tb.Expire(now.Add(time.Minute))
|
|
|
+ assert.Zero(t, tb.Len())
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func FuzzParse(f *testing.F) {
|
|
|
+ f.Add(append(Header{FragID: 1, MsgLen: 3000}.Encode(nil), make([]byte, 1000)...))
|
|
|
+ f.Add([]byte{0, 0, 0, 1, 0, 1, 0, 0, 7})
|
|
|
+ f.Fuzz(func(t *testing.T, b []byte) {
|
|
|
+ h, sym, err := Parse(b)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ require.Equal(t, b, append(h.Encode(nil), sym...))
|
|
|
+ l, err := h.Layout()
|
|
|
+ require.NoError(t, err)
|
|
|
+ require.LessOrEqual(t, l.SymbolSize, MaxSymbolSize)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// FuzzTable drives a small table with arbitrary fragments and checks its
|
|
|
+// bookkeeping after every step.
|
|
|
+func FuzzTable(f *testing.F) {
|
|
|
+ f.Add([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12})
|
|
|
+ f.Fuzz(func(t *testing.T, ops []byte) {
|
|
|
+ tb := NewTable[testKey, string](TableConfig{
|
|
|
+ MaxBytes: 4000,
|
|
|
+ MaxEntries: 3,
|
|
|
+ MaxPerSource: 2,
|
|
|
+ MaxMsgLen: 6000,
|
|
|
+ TTL: time.Second,
|
|
|
+ })
|
|
|
+ now := time.Unix(0, 0)
|
|
|
+ for len(ops) >= 4 {
|
|
|
+ src := string(rune('a' + ops[0]%4))
|
|
|
+ fragID := uint32(ops[1]%5) + 1
|
|
|
+ msgLen := uint16(ops[2])*32 + 1
|
|
|
+ index := ops[3]
|
|
|
+ ops = ops[4:]
|
|
|
+
|
|
|
+ h := Header{FragID: fragID, MsgLen: msgLen, Index: index}
|
|
|
+ l, err := h.Layout()
|
|
|
+ require.NoError(t, err)
|
|
|
+ sym := make([]byte, l.SymbolSize)
|
|
|
+ if index%7 == 0 {
|
|
|
+ now = now.Add(600 * time.Millisecond)
|
|
|
+ tb.Expire(now)
|
|
|
+ }
|
|
|
+ res, err := tb.Add(testKey{src, fragID}, src, now, h, sym)
|
|
|
+ if res != nil {
|
|
|
+ require.NoError(t, err)
|
|
|
+ require.Len(t, res.Msg, int(msgLen))
|
|
|
+ }
|
|
|
+ checkInvariants(t, tb)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|