Clock cached msg2 resends per path in the fragment table, so a copy of msg1 arriving by a relay or a second address is answered even when the direct path only works one way

This commit is contained in:
JackDoan committed 2026-10-14 15:17:24 -05:00
1 parent 1d0281cba5
commit bd82089a3f
8 files changed
+207 -57

No files matched your search

+81
View File
@@ -597,3 +597,84 @@ func TestTableAnswered(t *testing.T) {
assert.Empty(t, tb.answered)
tb.mu.Unlock()
}
// TestTableRepeatInterval checks that an answered message is reported as a
// repeat at most once per RepeatInterval on one key, while another key, a
// second path the same message arrives by, keeps its own clock.
func TestTableRepeatInterval(t *testing.T) {
r := rand.New(rand.NewPCG(4, 4))
cfg := testConfig()
cfg.RepeatInterval = 100 * time.Millisecond
tb := NewTable[testKey, string](cfg)
now := time.Unix(1000, 0)
msg := randMsg(r, 8009) // K=7, 3 repair
attempt := func(s *Sender) [][]byte {
data, repair, err := s.Attempt(DefaultRepair(s.Layout().K))
require.NoError(t, err)
return append(data, repair...)
}
repeats := func(key testKey, pkts [][]byte) int {
n := 0
for _, p := range pkts {
_, h, sym := parse(t, p)
res, err := tb.Add(key, key.src, now, h, sym)
require.NoError(t, err)
if res != nil {
require.True(t, res.Repeat)
n++
}
}
return n
}
answer := func(key testKey, s *Sender) {
var res *Result
for _, p := range attempt(s) {
_, h, sym := parse(t, p)
got, err := tb.Add(key, key.src, now, h, sym)
require.NoError(t, err)
if got != nil {
res = got
}
}
require.NotNil(t, res)
tb.Answered(key, res, now)
}
// Two paths carry the same message. Each has its own sender here because
// the initiator offers the same symbols on every path.
direct := testKey{"direct", 1}
relayed := testKey{"relay", 1}
sd, err := NewSender(msg, testSubtype, 0, 1, 1)
require.NoError(t, err)
sr, err := NewSender(msg, testSubtype, 0, 1, 1)
require.NoError(t, err)
answer(direct, sd)
answer(relayed, sr)
// A retry right after the answer carries K fresh symbols, but the clock on
// that path has not run out.
now = now.Add(10 * time.Millisecond)
assert.Zero(t, repeats(direct, attempt(sd)), "a repeat inside the interval is not reported")
// Once it has, the first fresh symbol of the next retry is enough: the
// fresh symbols kept counting.
now = now.Add(100 * time.Millisecond)
retry := attempt(sd)
assert.Equal(t, 1, repeats(direct, retry[:1]), "the held repeat is reported at once")
assert.Zero(t, repeats(direct, retry[1:]), "and only once per retry")
// The other path has its own clock, which only its own answer started.
assert.Equal(t, 1, repeats(relayed, attempt(sr)), "a repeat on another path is independent")
assert.Zero(t, repeats(relayed, attempt(sr)), "and is itself clocked")
// Without an interval every K fresh symbols is a repeat, which is what the
// other tests rely on.
tb = NewTable[testKey, string](testConfig())
s2, err := NewSender(msg, testSubtype, 0, 1, 2)
require.NoError(t, err)
k2 := testKey{"a", 2}
answer(k2, s2)
assert.Equal(t, 1, repeats(k2, attempt(s2)))
assert.Equal(t, 1, repeats(k2, attempt(s2)))
}
+24 -12
View File
@@ -24,6 +24,10 @@ type TableConfig struct {
MaxPerSource int // messages in progress per source
MaxMsgLen int // largest message accepted
TTL time.Duration // an entry idle this long is dropped
// RepeatInterval is how often, at most, an answered message is reported as a
// repeat on one key. Zero reports every K fresh symbols.
RepeatInterval time.Duration
}
// Table reassembles messages from senders that have not authenticated yet, the
@@ -38,7 +42,10 @@ type TableConfig struct {
// still arriving for it do not start it over. Without that, the repair
// fragments trailing a message whose data fragments already completed it would
// open a new entry that, for K above the repair count, never completes: it holds
// symbols and a source's quota until it expires.
// symbols and a source's quota until it expires. A retry of an answered message
// is reported as a repeat, at most once per RepeatInterval on that key. Keys
// include the path a message arrives by, so the same message arriving by two
// paths is two keys with two clocks.
//
// A Table is safe for concurrent use.
type Table[K, S comparable] struct {
@@ -55,13 +62,14 @@ type Table[K, S comparable] struct {
// indexes have been seen, so a retry's fresh symbols can be told apart from
// stragglers of an attempt already decoded.
type answeredEntry struct {
hash [sha256.Size]byte
msgLen uint16
k int
seen [fec.MaxSymbols]bool
fresh int
received int
lastSeen time.Time
hash [sha256.Size]byte
msgLen uint16
k int
seen [fec.MaxSymbols]bool
fresh int
received int
lastSeen time.Time
lastRepeat time.Time // when it was answered, or last reported as a repeat
}
type tableEntry[K, S comparable] struct {
@@ -107,7 +115,7 @@ func (t *Table[K, S]) Add(key K, source S, now time.Time, h Header, sym []byte)
t.mu.Lock()
if a, ok := t.answered[key]; ok {
if now.Sub(a.lastSeen) <= t.cfg.TTL {
res := a.add(now, h, sym)
res := a.add(now, h, sym, t.cfg.RepeatInterval)
t.mu.Unlock()
return res, nil
}
@@ -165,7 +173,7 @@ func (t *Table[K, S]) Add(key K, source S, now time.Time, h Header, sym []byte)
}, nil
}
func (a *answeredEntry) add(now time.Time, h Header, sym []byte) *Result {
func (a *answeredEntry) add(now time.Time, h Header, sym []byte, interval time.Duration) *Result {
if h.MsgLen != a.msgLen {
return nil
}
@@ -176,12 +184,16 @@ func (a *answeredEntry) add(now time.Time, h Header, sym []byte) *Result {
}
a.seen[h.Index] = true
a.fresh++
if a.fresh < a.k {
if a.fresh < a.k || now.Sub(a.lastRepeat) < interval {
// Not enough fresh symbols yet, or this key was answered within the
// interval. The fresh symbols keep counting, so the first one to arrive
// once the interval is up reports the repeat.
return nil
}
r := &Result{Hash: a.hash, Received: a.received, Repeat: true}
a.fresh = 0
a.received = 0
a.lastRepeat = now
return r
}
@@ -205,7 +217,7 @@ func (t *Table[K, S]) Answered(key K, res *Result, now time.Time) {
break
}
}
t.answered[key] = &answeredEntry{hash: res.Hash, msgLen: res.msgLen, k: res.k, seen: res.seen, lastSeen: now}
t.answered[key] = &answeredEntry{hash: res.Hash, msgLen: res.msgLen, k: res.k, seen: res.seen, lastSeen: now, lastRepeat: now}
}
// Forget drops what Answered recorded for key, so its fragments are reassembled