Documentation
¶
Overview ¶
Package netchaos provides deterministic, in-process simulated net.Conn and net.Listener implementations for Go tests. A Network, created with NewNetwork, lets peers Dial and Listen against each other subject to a configurable fault policy: latency, packet loss, and network partition. It is meant to be imported directly into a test — no external process, proxy, or daemon — so a test can prove its retry logic, timeout handling, or circuit breaker reacts correctly to a bad network.
See the package-level Example for a minimal dial/listen round trip, and the other Example functions for latency, packet loss, partition, and seeding.
Determinism ¶
A Network's fault sequence is reproducible from a seed (WithSeed): the same seed, with the same order of Dial/Listen/Partition/Heal calls, always produces the same sequence of injected faults on every connection. Each connection derives its own RNG stream from the seed, its establishment order, and its direction, so one connection's fault sequence never depends on how the Go scheduler interleaved it with another's — concurrent reads and writes on already-established connections are fully covered by the guarantee.
The guarantee has a real limit: it is about the order Network methods are called in, not about wall-clock concurrency of establishment itself. If a test races two goroutines to Dial concurrently, which one gets which connection ordinal — and therefore which RNG stream — is decided by the scheduler, not the seed. Fix the dial order (e.g. dial sequentially before starting concurrent I/O) if a test needs to reproduce exactly.
Using netchaos with testing/synctest ¶
netchaos composes with testing/synctest so that injected latency costs no real wall-clock time. Doing so imposes constraints that otherwise surface as a confusing panic:
- A Network must be constructed inside the synctest.Test bubble that will use it. synctest panics if a channel or timer created inside a bubble is later operated on from outside it, and every conn, listener, and deadline timer a Network creates is reachable from goroutines running inside that bubble.
- Connections and listeners must not be shared across bubbles.
- A closure passed to synctest.Test should take the bubble's own *testing.T rather than closing over an outer one. Cleanup registered against the outer t (via t.Cleanup) runs only after the whole test function returns, by which point the bubble is gone.
netchaos also works correctly outside a synctest bubble — but then latency consumes real wall-clock time, so don't assume virtual time comes for free without one.
Fault composition ¶
When more than one fault is configured on the same connection direction, they are evaluated in one fixed order: partition, then packet loss, then latency. A partitioned unit is discarded before any random draw happens, so partition never perturbs the loss or latency sequence. Every other configured fault draws from its stream unconditionally, even if an earlier fault in the order already dropped the unit — a unit dropped by packet loss still draws (and discards) a latency duration. This keeps each fault's draw index locked to the unit index, which is what makes a fault trace diffable across runs.
Reproducing a failure ¶
A test that fails under fault injection can be reproduced by re-running it with the seed that produced the failure, passed via WithSeed — the same seed and the same call order always regenerate the same fault sequence.
Example ¶
Example dials a listener within a Network and exchanges one message, the minimal round trip every other example builds on.
package main
import (
"fmt"
"io"
"net"
"github.com/jpgomesr/netchaos"
)
// acceptOne starts a background Accept on l and returns a channel that
// delivers the accepted connection. Examples run outside a testing/synctest
// bubble (Example functions have no *testing.T to hand synctest.Test), so
// this is plain goroutine synchronization rather than a bubble-aware
// pattern.
func acceptOne(l net.Listener) <-chan net.Conn {
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
return accepted
}
func main() {
n := netchaos.NewNetwork()
l, err := n.Listen("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = l.Close() }()
accepted := acceptOne(l)
client, err := n.Dial("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = client.Close() }()
server := <-accepted
defer func() { _ = server.Close() }()
if _, err := client.Write([]byte("ping")); err != nil {
fmt.Println(err)
return
}
buf := make([]byte, 4)
if _, err := io.ReadFull(server, buf); err != nil {
fmt.Println(err)
return
}
fmt.Println(string(buf))
}
Output: ping
Index ¶
- Variables
- func WithPeerName(ctx context.Context, name string) context.Context
- type Network
- func (n *Network) Dial(network, addr string) (net.Conn, error)
- func (n *Network) DialContext(ctx context.Context, network, dialAddr string) (net.Conn, error)
- func (n *Network) Heal(peerA, peerB string)
- func (n *Network) Listen(network, laddr string) (net.Listener, error)
- func (n *Network) Partition(peerA, peerB string)
- type Option
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrAddressInUse = errors.New("netchaos: address already in use")
ErrAddressInUse is returned by Network.Listen when the given address is already registered by another, still-open listener.
var ErrBacklogFull = errors.New("netchaos: accept backlog full")
ErrBacklogFull is returned by Network.Dial when the target listener's accept queue is full. It is distinct from ErrConnectionRefused so a test can tell "nobody is listening" apart from "the listener is overwhelmed."
var ErrConnectionRefused = errors.New("netchaos: connection refused")
ErrConnectionRefused is returned by Network.Dial/DialContext when no listener is registered for the target address, shaped like a real connection-refused failure so code under test takes the same path it would against a real closed port.
var ErrUnsupportedNetwork = errors.New("netchaos: unsupported network")
ErrUnsupportedNetwork is returned when a Dial/DialContext/Listen call names a network value netchaos doesn't support. v1 is TCP-shaped only: "tcp", "tcp4", and "tcp6" are accepted; "udp" and anything else are rejected, since UDP's datagram semantics are explicitly out of scope for v1 (see docs/06-scope-and-roadmap.md).
Functions ¶
func WithPeerName ¶
WithPeerName returns a copy of ctx that declares name as the calling peer's own identity for a subsequent DialContext call — the identity Network.Partition and Network.Heal target. Without it, a dialer gets a synthesized, unpartitionable ephemeral identity, usable for I/O like any other connection but never nameable by a Partition call and never blocked by one either.
Types ¶
type Network ¶
type Network struct {
// contains filtered or unexported fields
}
Network is a simulated network topology: a set of named peers that can Dial one another and Listen for connections, subject to a configurable fault policy (latency, packet loss, and partition).
Network intentionally has no Close method: netchaos spawns no goroutines of its own. The only asynchronous work is two time.AfterFunc callbacks — a deadline timer (deadline.go) and a per-pipe latency delivery timer (latency.go) — and both are owned by a single conn and stopped by conn.Close/pipe.close, so nothing outlives the conn that created it and there is nothing for a Network-wide Close to reap (see leak_test.go and synctest_test.go). These callbacks are also the only place a netchaos-internal goroutine acquires a sync.Mutex; the window is bounded because no lock is ever held across a blocking operation. Revisit this only if a resource appears that is not scoped to a single conn.
A Network must be constructed inside the testing/synctest bubble that will use it: synctest panics if a channel or timer created inside a bubble is later operated on from outside it, and every conn, listener, and deadline timer a Network creates is reachable from goroutines running inside that bubble.
A Network is safe for concurrent use by multiple goroutines.
func NewNetwork ¶
NewNetwork returns a new, empty Network configured by opts. Options are applied in argument order; a later option of the same kind overrides an earlier one. Every Option is validated once, after all of them have been applied — see Option and WithSeed for what that means for invalid values and for the determinism contract.
If WithSeed is not given, NewNetwork uses the fixed default seed 1 rather than a random one: netchaos's whole premise is that a test run is reproducible from its seed, so the common case of "just run the test" should be reproducible by default too. Because the default is a fixed constant, there is nothing to "recover" from a failing run — unlike a random default, which would need an accessor to report the seed it picked.
func (*Network) Dial ¶
Dial creates a simulated connection from the calling peer to addr within n. It is equivalent to DialContext(context.Background(), network, addr), and has exactly the shape of net.Dial so it can be used as a drop-in wherever calling code accepts a func(network, addr string) (net.Conn, error).
Example ¶
ExampleNetwork_Dial shows the error a Dial into an address nothing is listening on returns: a connection-refused-shaped error, matching what a real closed port would produce.
package main
import (
"errors"
"fmt"
"github.com/jpgomesr/netchaos"
)
func main() {
n := netchaos.NewNetwork()
_, err := n.Dial("tcp", "nobody-listening")
fmt.Println(errors.Is(err, netchaos.ErrConnectionRefused))
}
Output: true
func (*Network) DialContext ¶
DialContext creates a simulated connection from the calling peer to addr within n. The dial is aborted, returning ctx.Err(), if ctx is cancelled before the connection is established.
If the calling peer named itself via WithPeerName and that name is partitioned from addr, DialContext blocks until the partition is healed or ctx is done — a partition drops the SYN, so a real dial into a partitioned peer hangs the same way. A dialer that never calls WithPeerName is never partition-targetable (see WithPeerName), so it never blocks here regardless of any partition's state.
A dial that never establishes (blocked on a partition, then cancelled) does not consume a connection ordinal — ordinals, and therefore RNG streams (see the determinism contract on WithSeed), are assigned in the order dials complete, not the order they're attempted. This means the determinism contract does not apply to a dial that races a partition against cancellation; give it a context whose deadline is what should determine the outcome if that matters to a test.
Establishment is otherwise entirely synchronous: there is no blocking step between validating the request and enqueuing the new connection on the target listener. A full listener backlog fails immediately with ErrBacklogFull rather than waiting for room.
func (*Network) Heal ¶
Heal removes a previously established partition between the named peers, restoring traffic on any already-established connection between them without requiring a re-dial. A no-op if the pair is not currently partitioned — safe to call from a defer or test cleanup without tracking partition state separately.
func (*Network) Listen ¶
Listen registers a simulated listener at addr within n. Connections dialed to addr from elsewhere in n are delivered to this listener's Accept. network must be "tcp", "tcp4", or "tcp6" (v1 is TCP-shaped only; "udp" is rejected). Listening on an address already registered by another open listener returns an error satisfying errors.Is(err, ErrAddressInUse).
func (*Network) Partition ¶
Partition drops all subsequent traffic between the named peers until Heal is called for the same pair. A no-op if the pair is already partitioned, and a no-op (not an error) if either peer has never Dialed or Listened — partitioning before either side has connected is legitimate test setup.
Partition affects both connection establishment (a Dial naming one of these peers as itself, via WithPeerName, blocks until Heal or its context expires) and already-established connections between the pair (writes are accepted and silently discarded; reads block until their deadline).
Example ¶
ExampleNetwork_Partition shows a partitioned pair silently discarding writes, and traffic resuming once the pair is healed -- without needing a re-dial. WithPeerName gives the dialer a stable, partition-targetable identity; a dialer that never calls it can never be named by Partition.
package main
import (
"context"
"fmt"
"io"
"net"
"github.com/jpgomesr/netchaos"
)
// acceptOne starts a background Accept on l and returns a channel that
// delivers the accepted connection. Examples run outside a testing/synctest
// bubble (Example functions have no *testing.T to hand synctest.Test), so
// this is plain goroutine synchronization rather than a bubble-aware
// pattern.
func acceptOne(l net.Listener) <-chan net.Conn {
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
return accepted
}
func main() {
n := netchaos.NewNetwork()
l, err := n.Listen("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = l.Close() }()
accepted := acceptOne(l)
ctx := netchaos.WithPeerName(context.Background(), "client")
client, err := n.DialContext(ctx, "tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = client.Close() }()
server := <-accepted
defer func() { _ = server.Close() }()
n.Partition("client", "server")
if _, err := client.Write([]byte("dropped")); err != nil {
fmt.Println(err)
return
}
n.Heal("client", "server")
if _, err := client.Write([]byte("delivered")); err != nil {
fmt.Println(err)
return
}
buf := make([]byte, len("delivered"))
if _, err := io.ReadFull(server, buf); err != nil {
fmt.Println(err)
return
}
fmt.Println(string(buf))
}
Output: delivered
type Option ¶
type Option func(*networkConfig)
Option configures a Network at construction time. No Option or NewNetwork call returns an error: an Option given an invalid value (e.g. a WithPacketLoss rate outside [0,1]) makes NewNetwork panic, analogous to regexp.MustCompile.
Validation runs once, after every Option passed to NewNetwork has been applied — not inside each Option's own closure. This means a later, valid Option of a given kind rescues an earlier, invalid one of the same kind: passing WithPacketLoss(-1) followed by WithPacketLoss(0.5) does not panic, because only the final packet-loss rate is checked.
func WithLatency ¶
WithLatency delays delivery of writes by a duration drawn uniformly from [min, max]. Passing an equal min and max applies fixed latency. Applies globally, to every connection the Network handles. Both min and max must be non-negative, and min must not exceed max; NewNetwork panics otherwise, naming WithLatency and the offending value.
Latency delays delivery; it never reorders. Units on one connection direction are released in write order even when a later unit draws a shorter delay.
When packet loss or partition is also configured on the same connection direction, latency is evaluated last, after both — see the package doc's section on fault composition.
Example ¶
ExampleWithLatency shows latency delaying delivery of a write without dropping or reordering it. The delay here is a fraction of a millisecond so the example runs fast; netchaos does not require any particular magnitude.
package main
import (
"fmt"
"io"
"net"
"time"
"github.com/jpgomesr/netchaos"
)
// acceptOne starts a background Accept on l and returns a channel that
// delivers the accepted connection. Examples run outside a testing/synctest
// bubble (Example functions have no *testing.T to hand synctest.Test), so
// this is plain goroutine synchronization rather than a bubble-aware
// pattern.
func acceptOne(l net.Listener) <-chan net.Conn {
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
return accepted
}
func main() {
n := netchaos.NewNetwork(netchaos.WithLatency(time.Millisecond, time.Millisecond))
l, err := n.Listen("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = l.Close() }()
accepted := acceptOne(l)
client, err := n.Dial("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = client.Close() }()
server := <-accepted
defer func() { _ = server.Close() }()
if _, err := client.Write([]byte("hello")); err != nil {
fmt.Println(err)
return
}
buf := make([]byte, 5)
if _, err := io.ReadFull(server, buf); err != nil {
fmt.Println(err)
return
}
fmt.Println(string(buf))
}
Output: hello
func WithPacketLoss ¶
WithPacketLoss drops whole Write calls with the given probability, in [0.0, 1.0]. Applies globally, to every connection the Network handles. A rate outside [0.0, 1.0], including NaN and +/-Inf, makes NewNetwork panic, naming WithPacketLoss and the offending rate.
The unit of loss is the Write call: each write is delivered or dropped as a whole, decided by a Bernoulli trial drawn from the connection direction's own seeded stream. A dropped write is a silent gap: it is discarded, the peer's Read never observes those bytes, and -- because io.Writer forbids a short count without a non-nil error -- the call that issued the write still reports n = len(p), nil. This mirrors what a real socket does when a packet is lost downstream: the sender's kernel doesn't know either.
Rate 0.0 drops nothing; rate 1.0 drops everything, which is behaviourally similar to (but distinct in intent, and in draw consumption, from) partitioning the same peer pair -- see Network.Partition.
When partition is also configured on the same connection direction, packet loss is evaluated after partition and before latency -- see the package doc's section on fault composition.
Example ¶
ExampleWithPacketLoss shows packet loss dropping some writes silently: the writer never sees an error, and the reader only ever observes the survivors, concatenated with no gap marker. A fixed seed makes which writes survive reproducible.
package main
import (
"fmt"
"io"
"net"
"github.com/jpgomesr/netchaos"
)
// acceptOne starts a background Accept on l and returns a channel that
// delivers the accepted connection. Examples run outside a testing/synctest
// bubble (Example functions have no *testing.T to hand synctest.Test), so
// this is plain goroutine synchronization rather than a bubble-aware
// pattern.
func acceptOne(l net.Listener) <-chan net.Conn {
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
return accepted
}
func main() {
n := netchaos.NewNetwork(netchaos.WithSeed(7), netchaos.WithPacketLoss(0.4))
l, err := n.Listen("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = l.Close() }()
accepted := acceptOne(l)
client, err := n.Dial("tcp", "server")
if err != nil {
fmt.Println(err)
return
}
server := <-accepted
defer func() { _ = server.Close() }()
const writes = 10
for i := 0; i < writes; i++ {
if _, err := client.Write([]byte{'A' + byte(i)}); err != nil {
fmt.Println(err)
return
}
}
_ = client.Close()
survivors, err := io.ReadAll(server)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%d of %d writes arrived: %q\n", len(survivors), writes, survivors)
}
Output: 5 of 10 writes arrived: "ACDGI"
func WithPartition ¶
WithPartition marks the named peers as partitioned from Network construction onward. Static for the Network's lifetime; see Network.Partition / Network.Heal for dynamic control during a test.
peerA and peerB must be non-empty and distinct; NewNetwork panics otherwise, naming WithPartition and the offending value.
Partition is evaluated first in fault composition: a partitioned unit is discarded before packet loss or latency is evaluated, and consumes no random draws -- see the package doc's section on fault composition.
func WithSeed ¶
WithSeed sets the seed a Network's per-connection random streams are derived from. If WithSeed is not given, NewNetwork uses the fixed default seed 1.
The determinism guarantee: for a fixed seed and a fixed order in which Dial, Listen, Partition, and Heal are called, every resulting connection produces an identical sequence of injected faults across runs and across machines. Each connection derives its own RNG stream from the seed, its establishment order, and its direction, so the guarantee holds regardless of how concurrently established connections do I/O afterward.
The limit: the guarantee covers the order Network methods are called in, not wall-clock concurrency of establishment itself. If two goroutines race to Dial concurrently, which one is assigned which connection ordinal — and therefore which RNG stream — is decided by the Go scheduler, not the seed, so the guarantee does not apply to that race. Dial sequentially before starting concurrent I/O if a test needs to reproduce exactly.
Example ¶
ExampleWithSeed shows that the same seed reproduces the exact same fault sequence: running the same scenario twice with WithSeed(42) produces identical packet-loss outcomes both times.
package main
import (
"fmt"
"io"
"net"
"github.com/jpgomesr/netchaos"
)
// acceptOne starts a background Accept on l and returns a channel that
// delivers the accepted connection. Examples run outside a testing/synctest
// bubble (Example functions have no *testing.T to hand synctest.Test), so
// this is plain goroutine synchronization rather than a bubble-aware
// pattern.
func acceptOne(l net.Listener) <-chan net.Conn {
accepted := make(chan net.Conn, 1)
go func() {
c, err := l.Accept()
if err == nil {
accepted <- c
}
}()
return accepted
}
func main() {
run := func() (string, error) {
n := netchaos.NewNetwork(netchaos.WithSeed(42), netchaos.WithPacketLoss(0.5))
l, err := n.Listen("tcp", "server")
if err != nil {
return "", err
}
defer func() { _ = l.Close() }()
accepted := acceptOne(l)
client, err := n.Dial("tcp", "server")
if err != nil {
return "", err
}
server := <-accepted
defer func() { _ = server.Close() }()
const writes = 8
for i := 0; i < writes; i++ {
if _, err := client.Write([]byte{'A' + byte(i)}); err != nil {
return "", err
}
}
_ = client.Close()
survivors, err := io.ReadAll(server)
return string(survivors), err
}
first, err := run()
if err != nil {
fmt.Println(err)
return
}
second, err := run()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(first == second)
}
Output: true