v0.7.0 4c16dd8

simulation.go RawHistoryFolder

1// Package quicnettest provides a deterministic, unprivileged UDP carriage
2// simulator for adverse-network tests of QUIC-like traffic.
3//
4// The package is test support: it is stdlib-only, runs entirely in user space,
5// and never needs netem, root, or another privileged facility. A [Proxy] sits
6// between a UDP client and a UDP server and impairs both directions
7// independently:
8//
9//   - deterministic packet loss,
10//   - deterministic packet duplication,
11//   - delay-based reordering,
12//   - constant latency plus bounded uniform jitter,
13//   - scheduled or manually toggled blackout windows,
14//   - NAT-style rebinding that changes the proxy's upstream source address.
15//
16// # Determinism
17//
18// Every random decision uses either the per-direction source seeded from
19// [Config.Seed] or the caller-supplied [Config.RNG], so a given seed and packet
20// sequence always produce the same decisions, in the documented order. Random
21// draws are consumed only for impairments that are enabled, so a zeroed
22// [LinkConfig] never touches the RNG. [Config.Clock] defaults to wall time; pass
23// a [ManualClock] to advance the schedule without sleeping.
24//
25// # Bounds
26//
27// The proxy runs exactly three goroutines (one reader per socket direction plus
28// one scheduler) regardless of traffic, and never spawns per-packet work. The
29// packet budget is [Config.QueueCapacity]: queued and deadline-pending packets
30// together may not exceed it. When the budget is exhausted the arriving packet
31// is dropped and counted in [LinkStats.OverflowDrops] instead of blocking the
32// reader. Packet buffers are bounded by [Config.MaxPacketSize], and larger
33// datagrams are dropped and counted in [LinkStats.OversizeDrops].
34//
35// # Shutdown
36//
37// [Proxy.Close] is idempotent, wakes every goroutine, and waits for them to
38// exit. Errors observed while shutting down are not counted as delivery errors,
39// so [Proxy.Stats] stays meaningful after Close.
40package quicnettest
41
42import (
43	"errors"
44	"fmt"
45	"math/rand"
46	"net"
47	"strconv"
48	"sync"
49	"time"
50)
51
52const (
53	// DefaultQueueCapacity bounds in-flight packets per proxy.
54	DefaultQueueCapacity = 256
55	// DefaultMaxPacketSize bounds a single datagram. QUIC-like traffic stays
56	// well below this; larger datagrams are dropped rather than buffered.
57	DefaultMaxPacketSize = 1500
58	seedStride           = int64(2654435761)
59)
60
61// Direction identifies one carriage direction of the proxy.
62type Direction int
63
64const (
65	// ToServer is the client -> proxy -> server direction.
66	ToServer Direction = iota
67	// ToClient is the server -> proxy -> client direction.
68	ToClient
69)
70
71// String implements fmt.Stringer.
72func (d Direction) String() string {
73	switch d {
74	case ToServer:
75		return "to-server"
76	case ToClient:
77		return "to-client"
78	default:
79		return "direction(" + strconv.Itoa(int(d)) + ")"
80	}
81}
82
83// RNG is the deterministic randomness seam for impairment decisions. The
84// standard library's *rand.Rand satisfies it. The default source is seeded per
85// direction and only ever touched by that direction's reader goroutine, so the
86// simulation is race-free and reproducible. An injected RNG is shared by both
87// directions and must be safe for concurrent use; it is intended for scripted
88// single-direction tests.
89type RNG interface {
90	Intn(n int) int
91}
92
93// Blackout is a window during which every packet on a link is dropped. Start and
94// End are relative to proxy creation, so the schedule never depends on wall
95// time.
96type Blackout struct {
97	Start time.Duration
98	End   time.Duration
99}
100
101// LinkConfig describes the impairment of one carriage direction. Percentages
102// are in [0,100]; 100 always triggers and 0 never draws from the RNG.
103type LinkConfig struct {
104	// BaseLatency is added to every packet before it is scheduled.
105	BaseLatency time.Duration
106	// Jitter adds a uniform random delay in [0, Jitter] on top of BaseLatency.
107	Jitter time.Duration
108	// LossPercent drops packets with uniform probability.
109	LossPercent int
110	// DuplicatePercent schedules one extra copy of a packet.
111	DuplicatePercent int
112	// DuplicateDelay separates the extra copy from the original deadline.
113	DuplicateDelay time.Duration
114	// ReorderPercent delays a packet by ReorderDelay, which moves it behind
115	// packets scheduled later in the same direction.
116	ReorderPercent int
117	// ReorderDelay is the reordering window.
118	ReorderDelay time.Duration
119	// Blackouts lists drop windows relative to proxy creation.
120	Blackouts []Blackout
121}
122
123func (l LinkConfig) validate(direction Direction) error {
124	if l.BaseLatency < 0 || l.Jitter < 0 || l.DuplicateDelay < 0 || l.ReorderDelay < 0 {
125		return fmt.Errorf("quicnettest: %s link durations must not be negative", direction)
126	}
127	for name, percent := range map[string]int{
128		"loss":      l.LossPercent,
129		"duplicate": l.DuplicatePercent,
130		"reorder":   l.ReorderPercent,
131	} {
132		if percent < 0 || percent > 100 {
133			return fmt.Errorf("quicnettest: %s link %s percent %d is outside [0,100]", direction, name, percent)
134		}
135	}
136	for i, window := range l.Blackouts {
137		if window.Start < 0 || window.End <= window.Start {
138			return fmt.Errorf("quicnettest: %s link blackout %d must satisfy 0 <= start < end", direction, i)
139		}
140	}
141	return nil
142}
143
144// Config configures a [Proxy].
145type Config struct {
146	// ListenAddr optionally selects the client-facing fixture address.
147	// Nil retains the ephemeral loopback default.
148	ListenAddr *net.UDPAddr
149	// ServerAddr is the UDP server the proxy forwards to. Required.
150	ServerAddr *net.UDPAddr
151	// Clock drives all scheduling. Defaults to [SystemClock].
152	Clock Clock
153	// Seed deterministically seeds the default per-direction RNG.
154	Seed int64
155	// RNG replaces the seeded source. Tests use it to script decisions.
156	RNG RNG
157	// QueueCapacity bounds queued and deadline-pending packets.
158	// Defaults to [DefaultQueueCapacity].
159	QueueCapacity int
160	// MaxPacketSize bounds a single datagram. Defaults to
161	// [DefaultMaxPacketSize].
162	MaxPacketSize int
163	// ToServer impairs client -> server traffic.
164	ToServer LinkConfig
165	// ToClient impairs server -> client traffic.
166	ToClient LinkConfig
167	// NATRebindAfterPackets changes the proxy's upstream source address after
168	// every N client -> server packets have been sent. Zero disables automatic
169	// rebinding; [Proxy.Rebind] is always available.
170	NATRebindAfterPackets int
171	// OnPacket, when set, observes every ingested packet. It is called from a
172	// reader goroutine and must not block.
173	OnPacket func(Event)
174}
175
176func (c Config) withDefaults() Config {
177	if c.Clock == nil {
178		c.Clock = SystemClock{}
179	}
180	if c.QueueCapacity <= 0 {
181		c.QueueCapacity = DefaultQueueCapacity
182	}
183	if c.MaxPacketSize <= 0 {
184		c.MaxPacketSize = DefaultMaxPacketSize
185	}
186	return c
187}
188
189func (c Config) validate() error {
190	if c.ServerAddr == nil {
191		return errors.New("quicnettest: ServerAddr is required")
192	}
193	if c.ServerAddr.Port < 1 || c.ServerAddr.Port > 65535 {
194		return fmt.Errorf("quicnettest: ServerAddr port %d is out of range", c.ServerAddr.Port)
195	}
196	if c.NATRebindAfterPackets < 0 {
197		return errors.New("quicnettest: NATRebindAfterPackets must not be negative")
198	}
199	if err := c.ToServer.validate(ToServer); err != nil {
200		return err
201	}
202	return c.ToClient.validate(ToClient)
203}
204
205// Decision is the deterministic impairment outcome for one ingested packet.
206type Decision struct {
207	// Drop reports that the packet was discarded.
208	Drop bool
209	// Blackout reports that a blackout window caused the drop.
210	Blackout bool
211	// Oversize reports that the datagram exceeded MaxPacketSize.
212	Oversize bool
213	// Duplicates is the number of extra copies scheduled (0 or 1).
214	Duplicates int
215	// Reordered reports that the reordering window applied.
216	Reordered bool
217	// ExtraDelay is the random component added on top of BaseLatency.
218	ExtraDelay time.Duration
219}
220
221// Event describes one ingested packet and its outcome.
222type Event struct {
223	Direction Direction
224	Sequence  uint64
225	Bytes     int
226	// At is the clock time at which the packet was ingested.
227	At time.Time
228	// Due is the first copy's delivery deadline; zero for drops.
229	Due      time.Time
230	Decision Decision
231}
232
233// LinkStats audits one direction.
234type LinkStats struct {
235	// Received counts in-budget packets accepted from the source socket.
236	// Oversize datagrams are counted only in OversizeDrops.
237	Received uint64
238	// Sent counts packets handed to the destination socket. It is incremented
239	// before the write, so it is stable once the peer has observed a packet.
240	Sent uint64
241	// LossDrops counts seeded loss drops.
242	LossDrops uint64
243	// BlackoutDrops counts drops inside a blackout window or manual blackout.
244	BlackoutDrops uint64
245	// OversizeDrops counts datagrams larger than MaxPacketSize.
246	OversizeDrops uint64
247	// OverflowDrops counts packets dropped because QueueCapacity was full.
248	OverflowDrops uint64
249	// Duplicates counts extra copies scheduled.
250	Duplicates uint64
251	// Reordered counts packets delayed by the reordering window.
252	Reordered uint64
253	// DeliveryErrors counts socket write failures outside shutdown.
254	DeliveryErrors uint64
255}
256
257// String implements fmt.Stringer.
258func (l LinkStats) String() string {
259	return fmt.Sprintf("received:%d sent:%d loss:%d blackout:%d oversize:%d overflow:%d duplicates:%d reordered:%d delivery-errors:%d",
260		l.Received, l.Sent, l.LossDrops, l.BlackoutDrops, l.OversizeDrops, l.OverflowDrops,
261		l.Duplicates, l.Reordered, l.DeliveryErrors)
262}
263
264// Stats is a snapshot of proxy counters.
265type Stats struct {
266	ToServer LinkStats
267	ToClient LinkStats
268	// StrayPackets counts upstream datagrams that did not come from ServerAddr
269	// or arrived before any client address was known.
270	StrayPackets uint64
271	// Rebinds counts successful upstream source-address changes.
272	Rebinds uint64
273	// RebindErrors counts automatic rebinds that failed to open a socket.
274	RebindErrors uint64
275}
276
277// String implements fmt.Stringer.
278func (s Stats) String() string {
279	return fmt.Sprintf("to-server{%s} to-client{%s} stray:%d rebinds:%d rebind-errors:%d",
280		s.ToServer, s.ToClient, s.StrayPackets, s.Rebinds, s.RebindErrors)
281}
282
283// impairer applies one LinkConfig to one direction.
284type impairer struct {
285	link  LinkConfig
286	rng   RNG
287	start time.Time
288}
289
290// decide applies impairments in a fixed order so a given seed and packet
291// sequence always produces the same decisions:
292//
293//  1. link blackout window,
294//  2. loss,
295//  3. duplication,
296//  4. reordering (extra ReorderDelay),
297//  5. jitter (uniform in [0, Jitter]).
298func (im *impairer) decide(now time.Time) Decision {
299	var decision Decision
300	if im.blackout(now) {
301		decision.Drop = true
302		decision.Blackout = true
303		return decision
304	}
305	if im.link.LossPercent > 0 && im.rng.Intn(100) < im.link.LossPercent {
306		decision.Drop = true
307		return decision
308	}
309	if im.link.DuplicatePercent > 0 && im.rng.Intn(100) < im.link.DuplicatePercent {
310		decision.Duplicates = 1
311	}
312	if im.link.ReorderPercent > 0 && im.rng.Intn(100) < im.link.ReorderPercent {
313		decision.Reordered = true
314		decision.ExtraDelay += im.link.ReorderDelay
315	}
316	if im.link.Jitter > 0 {
317		decision.ExtraDelay += time.Duration(im.rng.Intn(int(im.link.Jitter) + 1))
318	}
319	return decision
320}
321
322func (im *impairer) blackout(now time.Time) bool {
323	elapsed := now.Sub(im.start)
324	for _, window := range im.link.Blackouts {
325		if elapsed >= window.Start && elapsed < window.End {
326			return true
327		}
328	}
329	return false
330}
331
332// lockedRNG serializes an injected RNG shared by both directions.
333type lockedRNG struct {
334	mu  sync.Mutex
335	rng RNG
336}
337
338func (l *lockedRNG) Intn(n int) int {
339	l.mu.Lock()
340	defer l.mu.Unlock()
341	return l.rng.Intn(n)
342}
343
344func newRNG(seed int64, direction Direction) RNG {
345	return rand.New(rand.NewSource(seed + int64(direction)*seedStride))
346}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close