Kaynağa Gözat

tun: Share replay-window reservation credit across readers

Charge staged reservations to each tunnel instead of dividing a static share
of the window by routines. Several readers sending on one tunnel then share
its credit: the limit is half the peer replay window, assumed from
ReplayWindow, which must be a power of two of at least 8192 (checked at
compile time). The other half allows finite unreserved traffic (inline and
control sends, relay forwarding through SendVia) and network reordering;
unlimited bypass traffic or delay can still exceed it.

An outstanding-count cap alone would let a fast reader repeatedly send and
reacquire credit, overtaking a paused reader without bound. So once readers
overlap, spent credit is only recycled when every reservation has drained. A
sole ordered reader gets each completion refunded, since its later batches
cannot overtake its earlier ones. A reader that fails to acquire ends sole
ownership, so a busy owner cannot keep a larger waiter blocked forever. This
restores progress, not FIFO fairness. All transitions are a single CAS.

Waiting readers flush their own oldest batch before retrying and never hold a
partial acquisition: a failed multi-tunnel (relay) acquisition rolls back.
Credit is released only after the flush or an explicit drop. A read that alone
exceeds a tunnel's credit, such as a tiny-MSS superpacket, is sent segment by
segment, synchronously. Acquisition is per read, so pressure on one tunnel
also delays the other tunnels in the same read.

The combined staged transmit, receive and shared credit measured 15 to 25
Gbit/s over one tunnel with eight TCP streams between two 100 GbE hosts.

Tests model overtaking, ordered reuse, a blocked reader behind a busy owner,
and relay rollback with both window sizes, and run eight competing readers
plus unreserved control traffic on one tunnel, checking every packet is
accepted by the receiver's replay window in wire order.
Ryan Huber 5 gün önce
ebeveyn
işleme
e4f659a207
8 değiştirilmiş dosya ile 427 ekleme ve 28 silme
  1. 4 0
      connection_state.go
  2. 3 0
      examples/config.yml
  3. 5 5
      flow.go
  4. 183 0
      flow_budget.go
  5. 104 0
      flow_budget_test.go
  6. 12 0
      flow_test.go
  7. 38 21
      flow_tx.go
  8. 78 2
      flow_tx_test.go

+ 4 - 0
connection_state.go

@@ -14,6 +14,9 @@ import (
 )
 
 const (
+	// ReplayWindow is the build-time receive window and the assumed peer window
+	// for TX reservation credit. Use a power of two; peers must match or have a
+	// larger receive window. The bitmap costs ReplayWindow/8 bytes per tunnel.
 	ReplayWindow = 8192
 
 	// RehandshakeAfterMessages rolls keys inside the AES-GCM data-volume margin (~2^-36 advantage at 64KB frames).
@@ -41,6 +44,7 @@ type ConnectionState struct {
 	peerCert       *cert.CachedCertificate
 	initiator      bool
 	messageCounter atomic.Uint64
+	txBudget       txBudget
 	window         *Bits
 	decryptLock    sync.Mutex
 	writeLock      sync.Mutex

+ 3 - 0
examples/config.yml

@@ -281,6 +281,9 @@ tun:
   # Receive needs a listener that can hand off its batches, which Linux has.
   # IMPORTANT: staged senders require peers with at least the 8192-entry replay window of this release. Released
   # v1.11.x peers use 1024 and are incompatible. The window is not negotiated.
+  # Staged reservations may use half the window, the other half absorbs inline and control traffic, relay forwarding
+  # and network reordering; unlimited forwarding or delay can still exceed it. Waiting for credit on a busy tunnel
+  # also delays the other tunnels of the same read, and readers are not guaranteed fairness.
   #staged:
   #  workers: 0
 

+ 5 - 5
flow.go

@@ -19,11 +19,11 @@ import (
 // the crypto and then drops packets whose destination has closed. TX uses eight slots and RX six; at listen.batch=64
 // with GRO their buffers take about 9 MiB and 24 MiB per routine. Small-MSS TX arenas can grow.
 //
-// Metrics are registered only for active pipelines. flow.{tx,rx}.batches counts reads handed to the ordered stage.
-// flow.stage.<stage>.q<queue>.busy_ns locates the limiting stage: tx_read, tx_encrypt, tx_send, rx_read, rx_decrypt
-// or rx_write. Take rates; 1e9 ns/s fills a serial stage. These are elapsed times that exclude read syscalls and
-// downstream waits; descheduling inflates them and worker times add up across the pool. Small RX batches sent to the
-// writer include decryption in writer time.
+// Metrics are registered only for active pipelines. flow.{tx,rx}.batches counts reads handed to the ordered stage and
+// flow.tx.inline oversized reads the reader sent itself. flow.stage.<stage>.q<queue>.busy_ns locates the limiting
+// stage: tx_read, tx_encrypt, tx_send, rx_read, rx_decrypt or rx_write. Take rates; 1e9 ns/s fills a serial stage.
+// These are elapsed times that exclude read syscalls and downstream waits; descheduling inflates them and worker
+// times add up across the pool. Small RX batches sent to the writer include decryption in writer time.
 
 // stagedConfig is tun.staged. workers is per routine and direction; zero keeps processing inline.
 type stagedConfig struct {

+ 183 - 0
flow_budget.go

@@ -0,0 +1,183 @@
+package nebula
+
+import (
+	"sync/atomic"
+	"time"
+
+	"github.com/slackhq/nebula/overlay/tio"
+)
+
+// txBudget is the reservation credit of one tunnel, shared by every staged reader sending on it. The low 24 bits of
+// state are outstanding counters, the next 24 spent credit, and the top 16 the sole ordered reader (q+1); owner zero
+// means shared. Readers beyond the owner field's range always take the conservative shared path.
+//
+// A cap on outstanding counters alone is not enough: a fast reader could repeatedly send and reacquire credit,
+// overtaking a paused reader by arbitrarily far. So once readers overlap, or another reader is blocked, spent credit
+// is only recycled when ALL reservations have drained. A sole reader instead gets each ordered completion refunded,
+// its later batches cannot overtake its earlier ones. All transitions are one CAS, without locks.
+//
+// The credit is half the peer's replay window. The other half is left for uncredited sends and transport reordering:
+// inline and control traffic, and relay forwarding through SendVia, which takes counters on the relay tunnel without
+// credit. Unlimited bypass traffic or network delay can still exceed it. The window is not negotiated, so
+// ci.window.length, from the build-time ReplayWindow, is also the assumed peer window.
+type txBudget struct{ state atomic.Uint64 }
+
+const txBudgetMask = uint64(1<<24 - 1)
+
+// Fail compilation for unsupported build-time windows: a power of two, at least 8192, fitting the credit fields (up
+// to 16777216).
+const (
+	_ = uint64(txBudgetMask) - ReplayWindow/2
+	_ = uint64(ReplayWindow) - 8192
+	_ = uint64(0) - (ReplayWindow & (ReplayWindow - 1))
+)
+
+func txBudgetOwner(q int) uint64 {
+	if q < 0 || q >= 65535 {
+		return 0
+	}
+	return uint64(q + 1)
+}
+
+func (b *txBudget) acquire(n, limit, owner uint64) bool {
+	for {
+		old := b.state.Load()
+		pending, spent, who := old&txBudgetMask, (old>>24)&txBudgetMask, old>>48
+		if n > limit || spent > limit-n {
+			// A blocked reader ends sole ownership so ordered completions stop refunding credit. Otherwise a busy
+			// owner could keep a larger waiter blocked forever without ever sharing a reservation.
+			if who != 0 && who != owner && !b.state.CompareAndSwap(old, old&^(uint64(0xffff)<<48)) {
+				continue
+			}
+			return false
+		}
+		if pending == 0 {
+			who = owner
+		} else if who != owner {
+			who = 0
+		}
+		next := who<<48 | (spent+n)<<24 | (pending + n)
+		if b.state.CompareAndSwap(old, next) {
+			return true
+		}
+	}
+}
+
+// release runs only after the socket flush or an explicit drop. A rollback returns credit before any message counter
+// was allocated, as when a later tunnel of a relay or multi-tunnel batch could not acquire its own.
+func (b *txBudget) release(n uint64, rollback bool) {
+	for {
+		old := b.state.Load()
+		pending, spent, owner := old&txBudgetMask, (old>>24)&txBudgetMask, old>>48
+		pending -= n
+		if rollback || owner != 0 {
+			spent -= n
+		}
+		next := owner<<48 | spent<<24 | pending
+		if pending == 0 {
+			next = 0
+		}
+		if b.state.CompareAndSwap(old, next) {
+			return
+		}
+	}
+}
+
+func (ci *ConnectionState) txBudgetLimit() uint64 { return ci.window.length / 2 }
+
+func (r *txReserver) oversized() bool {
+	for _, t := range r.tunnels {
+		if t.n > t.ci.txBudgetLimit() {
+			return true
+		}
+	}
+	return false
+}
+
+func (r *txReserver) tryBudget() bool {
+	for i, t := range r.tunnels {
+		if !t.ci.txBudget.acquire(t.n, t.ci.txBudgetLimit(), r.owner) {
+			for _, acquired := range r.tunnels[:i] {
+				acquired.ci.txBudget.release(acquired.n, true)
+			}
+			return false
+		}
+	}
+	return true
+}
+
+// waitBudget acquires credit for every tunnel of the batch, never holding a partial acquisition while it waits. Each
+// reader first flushes its own oldest batch, flushes need no credit; with none, another reader holds all outstanding
+// work and can flush it independently. So there is no circular wait, even with relay tunnels acquired in opposite
+// orders. The sleep is only for readers with no local work, not the uncontended hot path. Acquisition is
+// all-or-nothing per read, so pressure on one tunnel also delays packets for idle tunnels in the read; splitting
+// would need separate slot ownership and coupled inner/relay reservations. Reader fairness is not guaranteed.
+func (p *stagedTx) waitBudget() (time.Duration, bool) {
+	if p.tryBudget() {
+		return 0, true
+	}
+	t := time.Now()
+	for {
+		if p.f.closed.Load() {
+			return time.Since(t), false
+		}
+		if !p.flushOldest() {
+			time.Sleep(50 * time.Microsecond)
+		}
+		if p.tryBudget() {
+			return time.Since(t), true
+		}
+	}
+}
+
+func (s *txSlot) releaseBudget() {
+	for _, t := range s.reservations {
+		t.ci.txBudget.release(t.n, false)
+	}
+	clear(s.reservations)
+	s.reservations = s.reservations[:0]
+}
+
+// sendLarge handles the unusual reads that exceed a whole tunnel's credit, such as tiny-MSS superpackets. It drains
+// the pipeline, then segments and sends them synchronously with credit for each segment, never exceeding the cap and
+// never filtering or running connection manager side effects twice. This deliberately slow path avoids a lone-batch
+// exception to the shared invariant.
+func (p *stagedTx) sendLarge(items []txItem) time.Duration {
+	t := time.Now()
+	for p.flushOldest() {
+	}
+	stall := time.Since(t)
+	for _, it := range items {
+		err := tio.SegmentSuperpacket(it.pkt, func(seg []byte) error {
+			if p.f.closed.Load() {
+				return nil
+			}
+			part := it
+			part.pkt, part.segs = tio.Packet{Bytes: seg}, 1
+			p.tunnels = p.tunnels[:0]
+			part.counter = p.need(part.hostinfo, part.ci, 1)
+			if part.relayHI != nil {
+				part.relayCounter = p.need(part.relayHI, part.relayHI.ConnectionState, 1)
+			}
+			d, ok := p.waitBudget()
+			stall += d
+			if !ok {
+				return nil
+			}
+			one := [1]txItem{part}
+			if reserved := p.reserve(one[:]); len(reserved) != 0 {
+				p.f.encryptTxItem(&reserved[0], p.nb, p.sb)
+				p.f.flushSendBatch(p.sb, p.q)
+			}
+			for _, t := range p.tunnels {
+				t.ci.txBudget.release(t.n, false)
+			}
+			return nil
+		})
+		if err != nil {
+			it.hostinfo.logger(p.f.l).Error("Failed to segment superpacket for send", "error", err)
+		}
+	}
+	clear(items)
+	return stall
+}

+ 104 - 0
flow_budget_test.go

@@ -0,0 +1,104 @@
+package nebula
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/require"
+)
+
+var budgetWindows = []uint64{8192, 65536}
+
+// TestTXBudgetOvertaking: a fast sender must not recycle credit around a stalled older reservation. A pure in-flight
+// count passes the cap assertion but fails this replay test.
+func TestTXBudgetOvertaking(t *testing.T) {
+	for _, window := range budgetWindows {
+		t.Run(fmt.Sprint(window), func(t *testing.T) {
+			var b txBudget
+			limit := window / 2
+			w := NewBits(window)
+			l := test.NewLogger()
+			require.True(t, b.acquire(1, limit, 1)) // counter 1 stalls
+			for counter := uint64(2); counter <= limit; counter++ {
+				require.True(t, b.acquire(1, limit, 2))
+				require.True(t, w.Update(l, counter))
+				b.release(1, false)
+			}
+			require.Equal(t, uint64(1), b.state.Load()&txBudgetMask)
+			require.False(t, b.acquire(1, limit, 2), "spent credit cannot be recycled while the oldest sender stalls")
+			// The other half of the window allows this many unreserved sends.
+			for counter := limit + 1; counter <= window; counter++ {
+				require.True(t, w.Update(l, counter))
+			}
+			require.True(t, w.Update(l, 1), "oldest reservation must still fit after using the whole margin")
+			b.release(1, false)
+			require.Zero(t, b.state.Load())
+			require.True(t, b.acquire(limit, limit, 2))
+			require.False(t, b.acquire(1, limit, 2))
+			b.release(limit, false)
+		})
+	}
+}
+
+// TestTXBudgetBlockedReader models eight 512-segment reads of a busy owner and a 568-segment waiter, without socket
+// timing. Alone, the owner gets each completion refunded, so it keeps eight batches in flight forever, even across its
+// own failed acquisitions. But even when the owner always retries first, its completions must stop refunding credit
+// once the waiter is blocked, and let the budget drain. Waiter 0 is the conservative ID of readers beyond the owner
+// field's range.
+func TestTXBudgetBlockedReader(t *testing.T) {
+	for _, waiter := range []uint64{1, 0} {
+		t.Run(fmt.Sprintf("waiter=%d", waiter), func(t *testing.T) {
+			var b txBudget
+			const limit, n, want = uint64(4096), uint64(512), uint64(568)
+			for range 8 {
+				require.True(t, b.acquire(n, limit, 2))
+			}
+			for range 100 {
+				require.False(t, b.acquire(n, limit, 2))
+				b.release(n, false) // the sole sender flushes its oldest batch
+				require.True(t, b.acquire(n, limit, 2))
+				require.Equal(t, 8*n, b.state.Load()&txBudgetMask)
+			}
+			require.False(t, b.acquire(want, limit, waiter))
+			for range 7 {
+				b.release(n, false)
+				require.False(t, b.acquire(n, limit, 2), "blocked reader must stop the owner's credit recycling")
+				require.False(t, b.acquire(want, limit, waiter))
+			}
+			b.release(n, false)
+			require.Zero(t, b.state.Load())
+			// Even if the former owner wins the next acquisition, both now fit.
+			require.True(t, b.acquire(n, limit, 2))
+			require.True(t, b.acquire(want, limit, waiter))
+			b.release(n, false)
+			b.release(want, false)
+			require.Zero(t, b.state.Load())
+		})
+	}
+}
+
+// TestTXBudgetRelayRollback: when a later tunnel of a batch cannot acquire, the earlier ones give their credit back,
+// spent credit included.
+func TestTXBudgetRelayRollback(t *testing.T) {
+	for _, window := range budgetWindows {
+		inner := &ConnectionState{window: NewBits(window)}
+		outer := &ConnectionState{window: NewBits(window)}
+		limit := inner.txBudgetLimit()
+		require.True(t, inner.txBudget.acquire(10, limit, 1))
+		require.True(t, outer.txBudget.acquire(limit, limit, 1))
+		r := txReserver{owner: 2, tunnels: []txTunnel{{ci: inner, n: 20}, {ci: outer, n: 20}}}
+		for range 100 {
+			require.False(t, r.tryBudget())
+			require.Equal(t, uint64(10), inner.txBudget.state.Load()&txBudgetMask)
+			require.Equal(t, uint64(10), (inner.txBudget.state.Load()>>24)&txBudgetMask, "rollback must refund spent credit too")
+		}
+		outer.txBudget.release(limit, false)
+		require.True(t, r.tryBudget())
+		inner.txBudget.release(20, false)
+		outer.txBudget.release(20, false)
+		inner.txBudget.release(10, false)
+		require.Zero(t, inner.txBudget.state.Load())
+		require.Zero(t, outer.txBudget.state.Load())
+	}
+}

+ 12 - 0
flow_test.go

@@ -367,6 +367,18 @@ func stagedName(sc stagedConfig) string {
 	}
 }
 
+// send takes one read through the pipeline, as run does.
+func (p *stagedTx) send(pkts []tio.Packet) {
+	p.handle(pkts, &firewall.ParsedPacket{}, make([]byte, mtu), nil)
+}
+
+// drain waits until every batch p has in flight is on the wire.
+func (p *stagedTx) drain() {
+	for p.inFlight > 0 {
+		p.release(<-p.returned)
+	}
+}
+
 // TestFlowStagedFallback: a tun queue that cannot hand off its buffer, or a conn that cannot hand off its batches,
 // keeps that direction on the inline path.
 func TestFlowStagedFallback(t *testing.T) {

+ 38 - 21
flow_tx.go

@@ -11,6 +11,7 @@ import (
 	"github.com/slackhq/nebula/noiseutil"
 	"github.com/slackhq/nebula/overlay/batch"
 	"github.com/slackhq/nebula/overlay/tio"
+	"github.com/slackhq/nebula/udp"
 )
 
 var errFlowSegmentOverrun = errors.New("superpacket has more segments than counters reserved for it")
@@ -142,6 +143,7 @@ type txTunnel struct {
 type txReserver struct {
 	f       *Interface
 	tunnels []txTunnel
+	owner   uint64
 }
 
 // collect runs the filter over pkts and appends the sendable ones to items, tallying each tunnel's counter needs.
@@ -252,8 +254,8 @@ type txSlot struct {
 	buf  []byte
 	sb   *batch.SendBatch
 	done chan struct{}
-	// reserved is how many counters the batch reserved, for the reader's maxReserved budget
-	reserved uint64
+	// reservations hold shared tunnel credit until the flush, including for dropped counters
+	reservations []txTunnel
 }
 
 func (s *txSlot) run(nb []byte) {
@@ -272,6 +274,9 @@ type stagedTx struct {
 	senderCPU int // -1 leaves the sender within its inherited affinity
 	queue     handoffQueue
 	pool      *flowPool
+	// sb and nb are the reader's own, for the sends it does itself
+	sb *batch.SendBatch
+	nb []byte
 
 	// free holds the reader's idle slots. A dispatched slot goes to the workers and, in the same order, to the
 	// sender on ordered; the sender gives it back on returned once it is on the wire.
@@ -280,30 +285,30 @@ type stagedTx struct {
 	returned   chan *txSlot
 	inFlight   int
 	senderDone chan struct{}
-	// reserved is the counters held by in-flight slots, maxReserved this reader's half-window share
-	reserved    uint64
-	maxReserved uint64
 
 	readBusy, encryptBusy, sendBusy metrics.Counter
 	metricBatches                   metrics.Counter
+	metricInline                    metrics.Counter
 }
 
 // newStagedTx needs a queue that hands over whole read batches, the offload (TSO/USO) queue on linux. On any other
 // queue a read is one packet, and staging it would cost a handoff per packet.
 func newStagedTx(f *Interface, queue handoffQueue, q int) *stagedTx {
 	p := &stagedTx{
-		txReserver:    txReserver{f: f, tunnels: make([]txTunnel, 0, 4)},
+		txReserver:    txReserver{f: f, tunnels: make([]txTunnel, 0, 4), owner: txBudgetOwner(q)},
 		q:             q,
 		senderCPU:     -1,
 		queue:         queue,
+		sb:            batch.NewSendBatch(f.writers[q], batch.SendBatchCap, batch.SendBatchCap*(udp.MTU+32)),
+		nb:            make([]byte, 12),
 		ordered:       make(chan *txSlot, stagedTxSlots),
 		returned:      make(chan *txSlot, stagedTxSlots),
 		senderDone:    make(chan struct{}),
-		maxReserved:   (ReplayWindow / 2) / uint64(max(f.routines, 1)),
 		readBusy:      stageBusy("tx_read", q),
 		encryptBusy:   stageBusy("tx_encrypt", q),
 		sendBusy:      stageBusy("tx_send", q),
 		metricBatches: metrics.GetOrRegisterCounter("flow.tx.batches", nil),
+		metricInline:  metrics.GetOrRegisterCounter("flow.tx.inline", nil),
 	}
 	if f.pinThreads {
 		p.cpu = f.threadCPU(q)
@@ -363,21 +368,26 @@ func (p *stagedTx) handle(pkts []tio.Packet, fwPacket *firewall.ParsedPacket, re
 		return stall
 	}
 
-	var total uint64
-	for _, t := range p.tunnels {
-		total += t.n
+	if p.oversized() {
+		p.metricInline.Inc(1)
+		stall += p.sendLarge(items)
+		slot.items = items[:0]
+		p.free = append(p.free, slot)
+		return stall
 	}
-	// Over this reader's share of the window, wait for its oldest batches to go out first. A lone batch always goes.
-	if p.inFlight > 0 && p.reserved+total > p.maxReserved {
-		t := time.Now()
-		for p.inFlight > 0 && p.reserved+total > p.maxReserved {
-			p.release(<-p.returned)
-		}
-		stall += time.Since(t)
+	d, ok := p.waitBudget()
+	stall += d
+	if !ok {
+		clear(items)
+		slot.items = items[:0]
+		p.free = append(p.free, slot)
+		return stall
 	}
+	slot.reservations = append(slot.reservations[:0], p.tunnels...)
 	items = p.reserve(items)
 	slot.items = items
 	if len(items) == 0 {
+		slot.releaseBudget()
 		p.free = append(p.free, slot)
 		return stall
 	}
@@ -385,8 +395,6 @@ func (p *stagedTx) handle(pkts []tio.Packet, fwPacket *firewall.ParsedPacket, re
 	// The packets now live in the slot, and the queue reads into the slot's old buffer
 	slot.buf = p.queue.SwapRxBuf(slot.buf)
 
-	slot.reserved = total
-	p.reserved += total
 	p.inFlight++
 	p.metricBatches.Inc(1)
 	// Counters were reserved above on this goroutine and the slot takes its place in the sender's order right here,
@@ -422,9 +430,17 @@ func (p *stagedTx) reap() {
 	}
 }
 
+// flushOldest waits for the oldest batch in flight to reach the wire and takes its slot back. It reports false when
+// nothing is in flight.
+func (p *stagedTx) flushOldest() bool {
+	if p.inFlight == 0 {
+		return false
+	}
+	p.release(<-p.returned)
+	return true
+}
+
 func (p *stagedTx) release(s *txSlot) {
-	p.reserved -= s.reserved
-	s.reserved = 0
 	p.inFlight--
 	p.free = append(p.free, s)
 }
@@ -444,6 +460,7 @@ func (p *stagedTx) sender() {
 		} else {
 			p.f.flushSendBatch(s.sb, p.q)
 		}
+		s.releaseBudget()
 		p.sendBusy.Inc(int64(time.Since(t)))
 		clear(s.items)
 		s.items = s.items[:0]

+ 78 - 2
flow_tx_test.go

@@ -7,6 +7,8 @@ import (
 	"net/netip"
 	"os"
 	"os/exec"
+	"slices"
+	"sync"
 	"testing"
 	"time"
 
@@ -115,20 +117,27 @@ func TestFlowStagedTXExhausted(t *testing.T) {
 	require.Empty(t, sender.conn.captured())
 	require.Equal(t, RejectAfterMessages, ci.messageCounter.Load())
 	require.Equal(t, int64(12), sender.f.messageMetrics.txExhausted.Count())
+	require.Zero(t, ci.txBudget.state.Load())
 }
 
 // TestFlowStagedTXRelay sends staged batches, and an empty read, through a relay: outer and inner layers both verify,
 // in order, and go to the relay.
+// With a tiny MSS, the middle superpacket alone exceeds the tunnels' whole credit: it must be split, not admitted as an
+// unbounded lone batch, with either replay window.
 func TestFlowStagedTXRelay(t *testing.T) {
 	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
-	for _, window := range []uint64{ReplayWindow} {
+	for _, window := range budgetWindows {
 		t.Run(fmt.Sprintf("window=%d", window), func(t *testing.T) {
 			sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(2))
 			sender.hostinfo.ConnectionState.window = NewBits(window)
 			receiver.hostinfo.ConnectionState.window = NewBits(window)
-			relayRemote, _, relayPeer := addTestRelay(t, sender, window)
+			relayRemote, relayHI, relayPeer := addTestRelay(t, sender, window)
 			batches, want := flowTestBatches(2, 3, 1, 6)
 			var payload []byte
+			large := flowTCPSuperpacket(0, 9, 45)
+			large.GSO.Size = 1
+			payload = append([]byte(nil), large.Bytes[40:]...)
+			batches = [][]tio.Packet{batches[0], {large}, batches[1]}
 			sender.f.listenIn(newFlowBatchQueue(append(batches, nil)), 0)
 			wire := sender.conn.captured()
 			require.Len(t, wire, len(want)+len(payload))
@@ -156,6 +165,8 @@ func TestFlowStagedTXRelay(t *testing.T) {
 			}
 			require.Equal(t, want, got)
 			require.Equal(t, payload, tiny)
+			require.Zero(t, sender.hostinfo.ConnectionState.txBudget.state.Load())
+			require.Zero(t, relayHI.ConnectionState.txBudget.state.Load())
 		})
 	}
 }
@@ -329,3 +340,68 @@ func TestFlowStagedTXAffinity(t *testing.T) {
 func sendPaths() []stagedConfig {
 	return []stagedConfig{{}, stagedOn(2)}
 }
+
+// requireAccepted checks the receiver accepts every packet of wire in the order given. With several readers the wire
+// order differs from counter order, but every packet must still authenticate and fit within the replay window.
+func requireAccepted(t *testing.T, receiver *flowTestPeer, wire [][]byte) {
+	t.Helper()
+	for _, pkt := range wire {
+		h := &header.H{}
+		require.NoError(t, h.Parse(pkt))
+		_, err := receiver.hostinfo.ConnectionState.Decrypt(receiver.f.l, h.MessageCounter, pkt, make([]byte, 12))
+		require.NoError(t, err, "counter %d", h.MessageCounter)
+	}
+}
+
+// slowConn delays every WriteBatch, then writes to the shared capture.
+type slowConn struct {
+	*flowCaptureConn
+	delay time.Duration
+}
+
+func (c slowConn) WriteBatch(bufs [][]byte, addrs []netip.AddrPort) (int, error) {
+	time.Sleep(c.delay)
+	return c.flowCaptureConn.WriteBatch(bufs, addrs)
+}
+
+// TestFlowTXBudgetManyReaders saturates one tunnel from eight readers, one of them on a slow socket that lets the
+// others overtake it, alongside a burst of unreserved control messages: in the order they reach the wire, every packet
+// is accepted by the receiver's replay window, so none was sent twice or displaced past the window.
+func TestFlowTXBudgetManyReaders(t *testing.T) {
+	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
+	for _, window := range budgetWindows {
+		t.Run(fmt.Sprintf("window=%d", window), func(t *testing.T) {
+			sender, receiver := newFlowTestPair(t, noise.CipherChaChaPoly, stagedOn(3))
+			ci := sender.hostinfo.ConnectionState
+			ci.window = NewBits(window)
+			receiver.hostinfo.ConnectionState.window = NewBits(window)
+			sender.f.routines = 8
+			sender.f.writers = append([]udp.Conn{slowConn{sender.conn, 4 * time.Millisecond}}, slices.Repeat(sender.f.writers, 8)...)
+			start := ci.messageCounter.Load()
+			var wg sync.WaitGroup
+			for q := range 8 {
+				wg.Go(func() {
+					batches, _ := flowTestBatches(28, 8, 2, 45)
+					p := newStagedTx(sender.f, newFlowBatchQueue(nil), q)
+					defer p.close()
+					for _, packets := range batches {
+						p.send(packets)
+					}
+				})
+			}
+			// A bounded burst of unreserved control messages uses the documented margin, on writer 8
+			wg.Go(func() {
+				for range 128 {
+					sender.f.sendNoMetrics(header.Test, header.TestRequest, ci, sender.hostinfo, flowTestUnder, []byte("x"), make([]byte, 12), make([]byte, mtu), 8)
+					time.Sleep(100 * time.Microsecond)
+				}
+			})
+			wg.Wait()
+			require.Zero(t, ci.txBudget.state.Load())
+			wire := sender.conn.captured()
+			require.Len(t, wire, 8*28*360+128)
+			require.Equal(t, start+uint64(len(wire)), ci.messageCounter.Load())
+			requireAccepted(t, receiver, wire)
+		})
+	}
+}