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	// ServerAddr is the UDP server the proxy forwards to. Required.
147	ServerAddr *net.UDPAddr
148	// Clock drives all scheduling. Defaults to [SystemClock].
149	Clock Clock
150	// Seed deterministically seeds the default per-direction RNG.
151	Seed int64
152	// RNG replaces the seeded source. Tests use it to script decisions.
153	RNG RNG
154	// QueueCapacity bounds queued and deadline-pending packets.
155	// Defaults to [DefaultQueueCapacity].
156	QueueCapacity int
157	// MaxPacketSize bounds a single datagram. Defaults to
158	// [DefaultMaxPacketSize].
159	MaxPacketSize int
160	// ToServer impairs client -> server traffic.
161	ToServer LinkConfig
162	// ToClient impairs server -> client traffic.
163	ToClient LinkConfig
164	// NATRebindAfterPackets changes the proxy's upstream source address after
165	// every N client -> server packets have been sent. Zero disables automatic
166	// rebinding; [Proxy.Rebind] is always available.
167	NATRebindAfterPackets int
168	// OnPacket, when set, observes every ingested packet. It is called from a
169	// reader goroutine and must not block.
170	OnPacket func(Event)
171}
172
173func (c Config) withDefaults() Config {
174	if c.Clock == nil {
175		c.Clock = SystemClock{}
176	}
177	if c.QueueCapacity <= 0 {
178		c.QueueCapacity = DefaultQueueCapacity
179	}
180	if c.MaxPacketSize <= 0 {
181		c.MaxPacketSize = DefaultMaxPacketSize
182	}
183	return c
184}
185
186func (c Config) validate() error {
187	if c.ServerAddr == nil {
188		return errors.New("quicnettest: ServerAddr is required")
189	}
190	if c.ServerAddr.Port < 1 || c.ServerAddr.Port > 65535 {
191		return fmt.Errorf("quicnettest: ServerAddr port %d is out of range", c.ServerAddr.Port)
192	}
193	if c.NATRebindAfterPackets < 0 {
194		return errors.New("quicnettest: NATRebindAfterPackets must not be negative")
195	}
196	if err := c.ToServer.validate(ToServer); err != nil {
197		return err
198	}
199	return c.ToClient.validate(ToClient)
200}
201
202// Decision is the deterministic impairment outcome for one ingested packet.
203type Decision struct {
204	// Drop reports that the packet was discarded.
205	Drop bool
206	// Blackout reports that a blackout window caused the drop.
207	Blackout bool
208	// Oversize reports that the datagram exceeded MaxPacketSize.
209	Oversize bool
210	// Duplicates is the number of extra copies scheduled (0 or 1).
211	Duplicates int
212	// Reordered reports that the reordering window applied.
213	Reordered bool
214	// ExtraDelay is the random component added on top of BaseLatency.
215	ExtraDelay time.Duration
216}
217
218// Event describes one ingested packet and its outcome.
219type Event struct {
220	Direction Direction
221	Sequence  uint64
222	Bytes     int
223	// At is the clock time at which the packet was ingested.
224	At time.Time
225	// Due is the first copy's delivery deadline; zero for drops.
226	Due      time.Time
227	Decision Decision
228}
229
230// LinkStats audits one direction.
231type LinkStats struct {
232	// Received counts in-budget packets accepted from the source socket.
233	// Oversize datagrams are counted only in OversizeDrops.
234	Received uint64
235	// Sent counts packets handed to the destination socket. It is incremented
236	// before the write, so it is stable once the peer has observed a packet.
237	Sent uint64
238	// LossDrops counts seeded loss drops.
239	LossDrops uint64
240	// BlackoutDrops counts drops inside a blackout window or manual blackout.
241	BlackoutDrops uint64
242	// OversizeDrops counts datagrams larger than MaxPacketSize.
243	OversizeDrops uint64
244	// OverflowDrops counts packets dropped because QueueCapacity was full.
245	OverflowDrops uint64
246	// Duplicates counts extra copies scheduled.
247	Duplicates uint64
248	// Reordered counts packets delayed by the reordering window.
249	Reordered uint64
250	// DeliveryErrors counts socket write failures outside shutdown.
251	DeliveryErrors uint64
252}
253
254// String implements fmt.Stringer.
255func (l LinkStats) String() string {
256	return fmt.Sprintf("received:%d sent:%d loss:%d blackout:%d oversize:%d overflow:%d duplicates:%d reordered:%d delivery-errors:%d",
257		l.Received, l.Sent, l.LossDrops, l.BlackoutDrops, l.OversizeDrops, l.OverflowDrops,
258		l.Duplicates, l.Reordered, l.DeliveryErrors)
259}
260
261// Stats is a snapshot of proxy counters.
262type Stats struct {
263	ToServer LinkStats
264	ToClient LinkStats
265	// StrayPackets counts upstream datagrams that did not come from ServerAddr
266	// or arrived before any client address was known.
267	StrayPackets uint64
268	// Rebinds counts successful upstream source-address changes.
269	Rebinds uint64
270	// RebindErrors counts automatic rebinds that failed to open a socket.
271	RebindErrors uint64
272}
273
274// String implements fmt.Stringer.
275func (s Stats) String() string {
276	return fmt.Sprintf("to-server{%s} to-client{%s} stray:%d rebinds:%d rebind-errors:%d",
277		s.ToServer, s.ToClient, s.StrayPackets, s.Rebinds, s.RebindErrors)
278}
279
280// impairer applies one LinkConfig to one direction.
281type impairer struct {
282	link  LinkConfig
283	rng   RNG
284	start time.Time
285}
286
287// decide applies impairments in a fixed order so a given seed and packet
288// sequence always produces the same decisions:
289//
290//  1. link blackout window,
291//  2. loss,
292//  3. duplication,
293//  4. reordering (extra ReorderDelay),
294//  5. jitter (uniform in [0, Jitter]).
295func (im *impairer) decide(now time.Time) Decision {
296	var decision Decision
297	if im.blackout(now) {
298		decision.Drop = true
299		decision.Blackout = true
300		return decision
301	}
302	if im.link.LossPercent > 0 && im.rng.Intn(100) < im.link.LossPercent {
303		decision.Drop = true
304		return decision
305	}
306	if im.link.DuplicatePercent > 0 && im.rng.Intn(100) < im.link.DuplicatePercent {
307		decision.Duplicates = 1
308	}
309	if im.link.ReorderPercent > 0 && im.rng.Intn(100) < im.link.ReorderPercent {
310		decision.Reordered = true
311		decision.ExtraDelay += im.link.ReorderDelay
312	}
313	if im.link.Jitter > 0 {
314		decision.ExtraDelay += time.Duration(im.rng.Intn(int(im.link.Jitter) + 1))
315	}
316	return decision
317}
318
319func (im *impairer) blackout(now time.Time) bool {
320	elapsed := now.Sub(im.start)
321	for _, window := range im.link.Blackouts {
322		if elapsed >= window.Start && elapsed < window.End {
323			return true
324		}
325	}
326	return false
327}
328
329// lockedRNG serializes an injected RNG shared by both directions.
330type lockedRNG struct {
331	mu  sync.Mutex
332	rng RNG
333}
334
335func (l *lockedRNG) Intn(n int) int {
336	l.mu.Lock()
337	defer l.mu.Unlock()
338	return l.rng.Intn(n)
339}
340
341func newRNG(seed int64, direction Direction) RNG {
342	return rand.New(rand.NewSource(seed + int64(direction)*seedStride))
343}

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