netchaos

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 18 Imported by: 0

README

netchaos banner

Deterministic network fault injection for Go, in-process, no infrastructure required.

testing/synctest virtualizes time. netchaos virtualizes network. Same philosophy: deterministic, in-process, zero extra infrastructure — the way the standard library would do it if it covered this layer too.

Go Reference golangci-lint

Status: v0.3.1 released. v1's simulated transport and its three core faults are implemented, tested, and documented; v0.2.0 added bandwidth throttling, packet duplication, data corruption, mid-stream connection reset, live mutation of latency/loss on established connections, and an exported fault trace; v0.3.0 adds runtime validation on Partition/Heal/Reset, a bounded wait on DialerFor via WithDialTimeout, payload size and corruption site on FaultEvent, and live mutation of duplication and corruption rates; v0.3.1 is a bug-fix patch on top of it (see below). The API is stable but not frozen until v1.0.0 — see docs/07 — Contributing. Requires Go 1.25+ (testing/synctest).

Full design documentation: docs/


What this is

netchaos is a Go library that provides simulated net.Conn and net.Listener implementations with deterministic fault injection: latency, packet loss, bandwidth throttling, packet duplication, data corruption, network partition, and mid-stream connection reset (see docs/05 — Fault Injection). It's designed to be imported directly into go test, with no external process, no proxy, and no daemon — and to compose naturally with Go's own testing/synctest for virtual time.

The goal is narrow and specific: let you write a unit test that proves your retry logic, timeout handling, or circuit breaker reacts correctly to a bad network — in milliseconds, deterministically, reproducibly, on any CI runner, with zero setup.

What this is not

This space already has strong, well-established tools — and netchaos deliberately does not try to replace any of them. It fills a specific gap between two different approaches:

Where it runs What it tests Setup cost
Toxiproxy (Shopify) External process, real TCP sockets Your whole binary against degraded real network Low, but requires infrastructure (proxy + real network) running
gosim Source-translated program, custom runtime Full-program determinism: network, disk, goroutine scheduling High — rewrites how your entire program executes
netchaos In-process, plain library Your business logic against simulated network faults, inside a normal go test Minimal — swap a net.Dial call for a factory

Concretely:

  • Not chaos engineering for production. Tools like Chaos Mesh or Toxiproxy-in-prod operate at runtime, against real infrastructure, with real blast radius. netchaos is dev-time only: it runs inside go test, with no daemon, no proxy, no Kubernetes involved.
  • Not full-program determinism. Projects like gosim (or Antithesis) aim to make an entire program deterministic — disk, syscalls, scheduling, everything. netchaos intentionally scopes to the network layer only. That's a deliberate boundary, not a hidden limitation: it's what makes incremental adoption in an existing codebase possible without rewriting anything.
  • Not an integration-testing tool. Toxiproxy proves your service survives real degraded network conditions while actually running. netchaos proves your logic — retries, timeouts, backoff — behaves correctly under a simulated scenario, without spending real wall-clock time or standing up any external dependency.

Who it's for

Go developers building distributed services (gRPC, HTTP between microservices, message queues) who want to unit test resilience behavior — retry policies, timeouts, backoff, circuit breakers — without pulling in heavyweight test infrastructure.

Who it's not for

  • SREs doing chaos engineering in production → look at Chaos Mesh or Litmus
  • Teams needing bit-for-bit determinism across an entire program → look at gosim or Antithesis
  • QA needing end-to-end / load testing against a real running service → look at Toxiproxy

Usage

package myservice_test

import (
	"testing"
	"testing/synctest"
	"time"

	"github.com/jpgomesr/netchaos"
)

func TestRetryOnPacketLoss(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		network := netchaos.NewNetwork(
			netchaos.WithPacketLoss(0.3),
			netchaos.WithLatency(50*time.Millisecond, 150*time.Millisecond),
			netchaos.WithSeed(42), // deterministic, reproducible failures
		)

		client := myservice.NewClient(network.Dial)

		got, err := client.FetchWithRetry("resource-id")
		if err != nil {
			t.Fatalf("expected retry to succeed despite packet loss, got: %v", err)
		}
		if got != "resource-id" {
			t.Fatalf("got %q, want %q", got, "resource-id")
		}
	})
}

myservice.NewClient/FetchWithRetry stand in for your own client and its retry policy — the only netchaos-specific line is myservice.NewClient(network.Dial), handing your client a func(network, addr string) (net.Conn, error) it can dial through. A fully self-contained, compiled version of this same scenario (a hand-rolled client and server in place of myservice) lives as TestReadmeUsageSnippet in example_test.go, along with runnable examples for each headline feature.

For a full, standalone project wired up with netchaos — not just an inline snippet — see jpgomesr/netchaos-example.

Scope for v1 (deliberately minimal)

To avoid the trap of "framework covering everything" — the same trap that turned a previous project into an unfinishable moving target — v1 is scoped tightly:

  • Simulated net.Conn / net.Listener (TCP-shaped) with pluggable fault injection
  • Latency injection (fixed and ranged)
  • Packet loss (probabilistic, seeded/deterministic)
  • Network partition (drop all traffic between two simulated peers)
  • Seeded randomness for reproducible failure scenarios
  • Integration with testing/synctest for virtual time

Explicitly out of scope for v1: reordering, disk fault injection, full syscall simulation, UDP support, protocol-level fault injection above the connection layer, and per-peer-pair scoping of latency/loss. Reordering and per-pair scoping are genuinely open for post-v1 consideration; the rest are excluded on a design principle, not sequencing — see docs/06 — Scope & Roadmap for which is which.

v0.2.0 additions

Everything above shipped as v0.1.0. v0.2.0 adds, on top of it:

  • Bandwidth throttling, packet duplication, and data corruption — three further fault kinds, composed into the same fixed evaluation order as latency and packet loss.
  • Mid-stream connection reset (Network.Reset) — an abrupt ECONNRESET on an already-established connection, closing the one gap Partition's silent-black-hole model deliberately leaves.
  • Live mutation of latency and packet loss (Network.SetLatency/Network.SetPacketLoss) — the same "already-established connections, not just future dials" semantics Partition/Heal have.
  • Network.DialerFor — a partition-targetable net.Dial-shaped dial function, for client constructors that only accept a plain dial func.
  • Host:port address structure — net.SplitHostPort now succeeds against a netchaos address, the way it does against a real one.
  • Network.Trace — exports the full per-connection fault decision log, so a test can assert an exact count ("exactly 3 writes dropped") instead of only the downstream symptom.
  • WithPipeBound/WithListenerBacklog — the connection buffer size and accept-queue depth, previously fixed constants, are now configurable.

Full detail on each: CHANGELOG.md for what shipped and why, docs/04 — API Design for the exact signatures and semantics, docs/05 — Fault Injection for the fault mechanics.

v0.3.0 additions

Everything above shipped as v0.2.0. v0.3.0 adds, on top of it:

  • Partition/Heal/Reset validate their arguments the way WithPartition already does — an empty peer name or a self-pair now panics naming the offending call, instead of silently doing nothing. Naming a peer that was never dialed or listened is still a legitimate no-op.
  • Network.DialerFor gains a bounded wait — DialerFor(name, netchaos.WithDialTimeout(d)) fails with an error satisfying errors.Is(err, context.DeadlineExceeded) after d instead of hanging until Heal against a partitioned peer. Plain DialerFor(name) keeps its original unbounded-wait behaviour.
  • FaultEvent gains payload size and corruption site — Size, CorruptedByte, and CorruptedBit let a corruption or duplication failure be diagnosed, or an exact byte reproduced, directly from Network.Trace().
  • Network.SetDuplication and Network.SetCorruption — live mutation of the duplication and corruption rates, the same "already-established connections, not just future dials" semantics SetLatency/SetPacketLoss already have.

Full detail on each: CHANGELOG.md for what shipped and why, docs/04 — API Design for the exact signatures and semantics. The Claude Code skill at .claude/skills/netchaos/ is a self-contained reference covering the whole v0.3.x surface, usable from any project that adds netchaos as a dependency.

v0.3.1 fixes

A patch release — no API change, nothing to update in calling code:

  • A reset connection that is also closed locally always reports net.ErrClosed, in either order, instead of net.ErrClosed or ECONNRESET at random.
  • Network.Trace() keeps its (Ordinal, Side, Seq) order when connections are dialed concurrently, e.g. from a connection pool.
  • A nil Option or DialerOption panics with a named message (netchaos: NewNetwork: nil Option at index N) instead of a nil-pointer dereference.
  • The package builds on plan9 again; CI now cross-builds for plan9, windows, darwin, js/wasm and wasip1.
  • The skill's dialer guidance is corrected: a plain, unnamed Dial ignores partitions and never blocks — only a named dial (DialerFor, WithPeerName) waits; only DialContext fits http.Transport.DialContext, and gRPC's WithContextDialer needs a one-line adapter.

Installation

Requires Go 1.25 or later — testing/synctest, which netchaos's virtual-time integration depends on, was introduced in Go 1.25 and cannot be used on an older toolchain.

go get github.com/jpgomesr/netchaos@v0.3.1

Contributing

netchaos v1 is implemented, tested, and documented. Bug reports and implementation PRs are welcome; see docs/07 — Contributing for what's most useful to contribute right now, and note the API is stable but not frozen until v1.0.0.

Using netchaos in a real project? A use-case report is the most useful thing you can send right now — it's the evidence the remaining post-v1 decisions and the v1.0.0 tag are waiting on.

License

MIT

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, bandwidth throttling, packet duplication, data corruption, network partition, and mid-stream connection reset. 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, bandwidth throttling, packet duplication, data corruption, partition, mid-stream reset, live fault mutation, the exported fault trace, buffer/backlog tuning, 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/SetLatency/ SetPacketLoss/SetDuplication/SetCorruption 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 bandwidth, then latency, then corruption, then duplication. Corruption runs before duplication deliberately, so a duplicated unit's second copy carries whatever corruption already did to the first, rather than an independently corrupted copy. A partitioned unit is discarded before any random draw happens, so partition never perturbs the loss, latency, corruption, or duplication sequence. A unit dropped by packet loss never reaches the bandwidth, corruption, or duplication stage either, so a dropped unit costs no simulated link time, is never corrupted, and is never duplicated. Every other configured fault that draws from a random stream does so 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, a corruption decision, and a duplication decision. Bandwidth is not part of that: it computes a deterministic delay from a unit's size and the configured rate, drawing nothing, so enabling it can never perturb any other fault's sequence. This keeps each drawing 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

Examples

Constants

This section is empty.

Variables

View Source
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.

View Source
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".

View Source
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.

View Source
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

func WithPeerName(ctx context.Context, name string) context.Context

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.

The name is a peer identity, not an address: a port written into it is stripped, exactly as it is on the addresses passed to Dial and Listen.

Use this when the dial needs a context anyway — to bound how long it waits on a partition, for instance. Network.DialerFor is the same capability for code that wants a plain net.Dial-shaped function to hand to a client constructor.

Types

type DialerOption added in v0.3.0

type DialerOption func(*dialerConfig)

DialerOption configures a dialer returned by Network.DialerFor. See WithDialTimeout, the only DialerOption v1 ships (M9-2, issue #86).

func WithDialTimeout added in v0.3.0

func WithDialTimeout(d time.Duration) DialerOption

WithDialTimeout bounds every dial made through the returned DialerFor closure to d: a dial that would otherwise block on a partitioned peer (see Network.Partition) instead fails once d elapses, with an error satisfying errors.Is(err, context.DeadlineExceeded), wrapped in a *net.OpError (M6-2's uniform shape) whose Timeout() reports true.

d must be positive; WithDialTimeout panics otherwise, naming itself and the offending value, following the existing panic-on-invalid convention (validateLatencyRange et al.). Unlike an Option passed to NewNetwork, this validates immediately when called rather than being deferred to a later validation pass -- there is no NewNetwork call downstream of a DialerOption to defer it to.

The timeout is scoped to each dial call, not to the DialerFor closure as a whole: a call that times out does not affect a later call from the same closure, and successive calls each get the full d.

type FaultEvent added in v0.2.0

type FaultEvent struct {
	Ordinal uint64
	Side    Side
	Seq     uint64

	Partitioned bool
	Dropped     bool
	Duplicated  bool
	Corrupted   bool

	Delay         time.Duration
	Serialization time.Duration
	Effective     time.Duration

	Size          int
	CorruptedByte int
	CorruptedBit  uint8
}

FaultEvent is one fault-injection decision recorded for a single Write unit (M0-3's fault unit) on one direction of one connection, exported by Network.Trace (M7-10, issue #51). It is faultEvent plus the (Ordinal, Side) identity needed to attribute an event to a connection direction once it has left traceRecorder's per-direction scope.

Ordinal and Side identify the connection direction this event belongs to: Ordinal is the connection's dial-order ordinal (the determinism contract, docs/04, already fixes this order), and Side is which end produced the event. Seq is this event's position within that direction's own trace, starting at 0.

Partitioned, Dropped, Duplicated and Corrupted are independent, not mutually exclusive — the draw discipline records Duplicated and Corrupted even for a unit Dropped already discarded, so more than one can be true on the same event. Partitioned is never true alongside any other field: a partitioned unit draws nothing (the determinism contract's zero-draw exception), so every other field on that event is its zero value.

Delay, Serialization and Effective are durations, and reading them correctly depends on which fault produced the event:

  • Delay is the duration drawn from WithLatency's/SetLatency's stream. Zero is ambiguous by itself: an explicit fixed-zero delay (WithLatency(0, 0) or SetLatency(0, 0)) still draws, so Delay == 0 does not distinguish "latency not configured" from "configured, drew zero" — the property this field exists to make observable in the first place.
  • Serialization is this unit's contribution to link-busy time under WithBandwidth; zero whenever bandwidth isn't configured.
  • Effective is the delay actually applied, measured from when serialization finished (not from when the unit was written), after any clamping against an already-pending unit ahead of it (M2-2). A unit's total delay from Write to delivery is Serialization + Effective.

On a Dropped event, Delay/Serialization/Effective describe what was drawn or computed before loss discarded the unit, not a delivery that happened: Delay may be non-zero for a unit that was never sent, and Serialization and Effective are always zero, since loss short-circuits installFaultPolicy's evaluator before either is computed (faults.go).

Size, CorruptedByte and CorruptedBit (M9-3, issue #78 -- payload size and corruption site; Reset attribution stays deferred, see docs/06) let a corruption or duplication failure be diagnosed, or an exact byte reproduced, without re-deriving the corruption stream by hand:

  • Size is the unit's payload length, in bytes, at the point it was evaluated. Always zero on a Partitioned event, since a partitioned unit is discarded before its size is ever recorded (the same zero-draw exception every other field there follows) -- but the converse does not hold: a zero-length Write also reports Size == 0 on a non-Partitioned event, so Size == 0 alone never implies Partitioned. Recorded on every event past the partition gate, including a Dropped one, since a unit's size is known and meaningful even when it was discarded.
  • CorruptedByte and CorruptedBit are the byte index and bit index (within that byte) corruptionSite drew, meaningful only when Corrupted && Size > 0 && !Dropped. corruptionSite is never called unless a unit survives both drops: dropped fires before mutation is even attempted (installFaultPolicy, faults.go), so a unit that is both Dropped and Corrupted still has a zero corruption site -- the Bernoulli trial was drawn, but the byte/bit draw it would have conditioned never ran. A zero-length write is the other case with no site: the corruption decision is still drawn (per the draw discipline), but there is no byte to flip, so corruptionSite is never called for it either. Both cases leave CorruptedByte/ CorruptedBit at zero even though Corrupted is true -- reading either field as "byte 0, bit 0 was flipped" without first checking Size > 0 and !Dropped is exactly the mistake this note exists to prevent.

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, bandwidth throttling, packet duplication, data corruption, partition, and mid-stream reset).

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 in terms of goroutines or timers (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.

traces (trace.go, M7-10) is the one resource that IS Network-scoped rather than conn-scoped, growing for as long as n keeps dialing: unlike resetTargets, an entry is deliberately never pruned when its conn closes, since Network.Trace exists to be read after the connection that produced it typically already has been (the common defer c.Close() case). That still needs no Close, though — the "nothing to reap" claim above was always about goroutines and timers, not about every field's lifetime matching a conn's, and traces holds neither: only recorder pointers whose memory is reclaimed with n itself. Revisit the "no Close" design only if a future resource needs active cleanup, not merely a lifetime wider than one conn's.

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

func NewNetwork(opts ...Option) *Network

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. A nil Option panics with a message naming its index (#114), like any other invalid option.

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

func (n *Network) Dial(network, addr string) (net.Conn, error)

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

func (n *Network) DialContext(ctx context.Context, network, dialAddr string) (net.Conn, error)

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 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 holds for every way a dial can fail, not just for a dial blocked on a partition and then cancelled: the accept-queue slot is claimed first, so a dial refused with ErrConnectionRefused or ErrBacklogFull returns before an ordinal is taken and leaves the sequence unshifted for every connection after it. What the contract does not cover is 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 handing the new connection to the target listener. A full listener backlog fails immediately with ErrBacklogFull rather than waiting for room.

dialAddr with no host at all (":0", ":8080", "") is rejected with a *net.AddrError: unlike Listen, which treats a hostless address as a wildcard bind, there is no localhost in a netchaos topology for a hostless dial to mean, so resolving one to some peer would always be a guess (issue #73).

func (*Network) DialerFor added in v0.2.0

func (n *Network) DialerFor(name string, opts ...DialerOption) func(network, addr string) (net.Conn, error)

DialerFor returns a dial function that names itself as name, so the connections it creates are targetable by Network.Partition and Network.Heal.

It exists because Dial cannot be. A dialer's identity travels on a context.Context (see WithPeerName), and Dial's net.Dial-shaped signature has no context parameter — so a Dial call could never carry a peer name, was never partition-targetable, and never blocked on a partition. That put partition, a headline feature, out of reach of the drop-in entry point the library's no-rewrite adoption claim rests on: a user who wanted both had to abandon the drop-in and rewrite their client to take a DialContext instead (issue #36, M5-2 finding F2).

What DialerFor returns is still exactly net.Dial's shape, which is the point — it goes wherever Dial went:

client := myservice.NewClient(n.DialerFor("client"))
n.Partition("client", "server") // now actually affects that client

name is a peer identity rather than an address: a port written into it is stripped, so DialerFor("client") and DialerFor("client:1234") name the same peer, the one Partition("client") targets.

One behaviour to know, because it is new for anyone reaching for this instead of Dial: a named dialer IS subject to the dial-time partition check, so dialing a peer this one is partitioned from blocks until Heal. A partition drops the SYN, so that is what a real dial does too. Without opts, there remains no way to bound that wait — use WithDialTimeout(d) to fail after d instead of hanging until Heal, or reach for DialContext with WithPeerName and your own context deadline instead (M9-2, issue #86).

A nil DialerOption in opts panics when DialerFor is called, naming its index (#114).

Example

ExampleNetwork_DialerFor shows the same partition scenario as ExampleNetwork_Partition, reached without a context. DialerFor returns a plain net.Dial-shaped function, so it goes straight into a client constructor that accepts one — and unlike Network.Dial, the connections it makes are targetable by Partition and Heal.

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)

	// Stands in for a client constructor: it accepts exactly what net.Dial
	// is, which is the property DialerFor exists to preserve.
	newClient := func(dial func(network, addr string) (net.Conn, error)) (net.Conn, error) {
		return dial("tcp", "server")
	}

	client, err := newClient(n.DialerFor("client"))
	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

func (*Network) Heal

func (n *Network) Heal(peerA, peerB string)

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.

Heal identifies peers exactly as Partition does, so the same naming caveat applies: a dialer that never named itself — with WithPeerName or Network.DialerFor — is not targetable under any name, and a Heal aimed at one is one of the silent no-ops above.

peerA and peerB must be non-empty and distinct, the same requirement WithPartition already enforces; Heal panics otherwise, naming Heal and the offending value (M9-1, issue #83).

func (*Network) Listen

func (n *Network) Listen(network, laddr string) (net.Listener, error)

Listen registers a simulated listener at laddr within n. Connections dialed to laddr 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).

laddr may be written with or without a port. "server" and "server:0" both ask netchaos to synthesize one, the way ":0" does against the real stack; "server:8080" keeps the port the caller wrote. Either way the peer's identity — what Partition, Heal and a dial resolve against — is the host half alone, so a listener on "server:8080" is still the peer named "server". A malformed address is rejected with a *net.AddrError, matching what net.Listen produces for the same input.

Addresses that name the same host collide regardless of port: a second Listen on "server:9090" while "server:8080" is open returns ErrAddressInUse. One peer, one listener — see the addr type for why identity deliberately stops at the host half.

laddr with no host at all (":0", ":8080", "") is a wildcard bind: Listen synthesizes a fresh identity for it, the way a real net.Listen(":0") on a separate machine would never collide with another (issue #73). Two such calls therefore never return ErrAddressInUse against each other, even with the same explicit port — the one place this diverges from a real single-process net.Listen, which would fail the second call with EADDRINUSE; netchaos models each hostless bind as its own machine's wildcard bind rather than as a shared one.

The consequence worth knowing: for a wildcard bind, the peer's name — what Partition, Heal and Reset target — is the host half of the returned Listener's Addr(), never the string originally passed to Listen (which named no host, and so names no peer at all). Read it back with net.SplitHostPort(l.Addr().String()) before calling Partition.

func (*Network) Partition

func (n *Network) Partition(peerA, peerB string)

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.

Naming a dialer is required, and dialing is not enough on its own: a dial that never names itself gets a synthesized ephemeral identity (see WithPeerName), so that peer is not targetable here under any name. This composes with the silent no-op above into a case worth stating outright — Partition("client", "server") returns without error, and traffic between them keeps flowing, if "client" only ever dialed as n.Dial("tcp", "server"). A listener is always targetable by its Listen address; only dialers need naming.

Two ways to name one, and the choice is about how the dial waits rather than about what is targetable:

client := myservice.NewClient(n.DialerFor("client"))
c, err := n.DialContext(WithPeerName(ctx, "client"), "tcp", "server")

Network.DialerFor keeps net.Dial's shape, so it drops into code that accepts a dial function; DialContext takes a context, so its wait on a partition can be bounded by a deadline.

Partition affects both connection establishment (a dial naming one of these peers as itself, via WithPeerName or Network.DialerFor, blocks until Heal or, if it has a context, until that context expires) and already-established connections between the pair (writes are accepted and silently discarded; reads block until their deadline).

peerA and peerB must be non-empty and distinct, the same requirement WithPartition already enforces; Partition panics otherwise, naming Partition and the offending value (M9-1, issue #83).

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

func (*Network) Reset added in v0.2.0

func (n *Network) Reset(peerA, peerB string)

Reset abruptly terminates every currently-established connection between the named peers: both ends' subsequent Read and Write calls, and any already in-flight on another goroutine, fail with an error satisfying errors.Is(err, syscall.ECONNRESET) wrapped in a *net.OpError (matching the uniform error shape M6-2 established). On plan9, whose syscall package has no ECONNRESET, it is instead a plain error with the same "connection reset by peer" message (#113). A reset connection stays reset -- there is no way to "un-reset" it, unlike a partition, which Heal reverses. The one exception is an end that is also closed locally: in either order, a closed end fails with net.ErrClosed, never ECONNRESET, the same as a real net.Conn closed after an RST (#111).

This is the gap Partition cannot fill: Partition is a deliberately silent black hole (writes accepted and discarded, reads blocking to their deadline), never an ECONNRESET. Testing "the peer dropped the connection abruptly" previously meant holding the server-side net.Conn and calling Close yourself -- something a test of client-side reconnect logic should not have to do.

Unlike Partition, Reset does not gate Dial: it has no effect on establishment, and no effect on a connection dialed after this call returns. It resets what exists at the moment it is called and nothing else -- a real RST invalidates existing TCP state, it does not prevent a fresh connection.

A no-op if no connection currently exists between the named peers -- there being nothing established to reset is not an error, the same no-op convention Partition and Heal already use for an unrecognized or not-yet-connected pair. peerA and peerB are resolved exactly as Partition/Heal resolve them (peerName strips a port, per M7-1), so the same naming caveat applies: an unnamed dialer's synthesized ephemeral-N identity is not one a caller can predict, so it is not practically targetable here either.

Reset takes no random draws and cannot perturb any connection's loss/latency/duplication/corruption sequence -- it is evaluated nowhere near installFaultPolicy's per-unit evaluator.

peerA and peerB must be non-empty and distinct, the same requirement WithPartition/Partition/Heal already enforce; Reset panics otherwise, naming Reset and the offending value (M9-1, issue #83).

Example

ExampleNetwork_Reset shows a reset abruptly terminating an established connection: both ends' subsequent calls fail with an error satisfying errors.Is(err, syscall.ECONNRESET). DialerFor is required here, not Dial -- Dial's unnamed dialer gets an unpredictable ephemeral-N identity that Reset cannot target.

package main

import (
	"errors"
	"fmt"
	"net"
	"syscall"

	"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.DialerFor("client")("tcp", "server")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = client.Close() }()
	server := <-accepted
	defer func() { _ = server.Close() }()

	n.Reset("client", "server")

	buf := make([]byte, 1)
	_, err = client.Read(buf)
	fmt.Println(errors.Is(err, syscall.ECONNRESET))
}
Output:
true

func (*Network) SetCorruption added in v0.3.0

func (n *Network) SetCorruption(rate float64)

SetCorruption changes the corruption rate applied to every connection in n, including connections already established — the same live semantics SetLatency and SetPacketLoss have (M9-4, issue #85 partial).

rate means exactly what WithCorruption's does and is validated the same way: outside [0.0, 1.0], including NaN and ±Inf, panics naming the offending value. SetCorruption(0.0) is an explicit never-corrupt policy rather than "off"; it still draws.

The same determinism note as SetLatency/SetPacketLoss applies: the setter is an ordered Network call (see the determinism contract's Runtime fault mutation section), its order against concurrent in-flight I/O is not fixed by the contract, and the draw discipline does not change. Enabling corruption with this setter when it was never configured at construction begins drawing from corruption's own independent stream from that point on, without shifting any other fault kind's sequence.

func (*Network) SetDuplication added in v0.3.0

func (n *Network) SetDuplication(rate float64)

SetDuplication changes the duplication rate applied to every connection in n, including connections already established — the same live semantics SetLatency and SetPacketLoss have (M9-4, issue #85 partial).

rate means exactly what WithDuplication's does and is validated the same way: outside [0.0, 1.0], including NaN and ±Inf, panics naming the offending value. SetDuplication(0.0) is an explicit never-duplicate policy rather than "off"; it still draws.

The same determinism note as SetLatency/SetPacketLoss applies: the setter is an ordered Network call (see the determinism contract's Runtime fault mutation section), its order against concurrent in-flight I/O is not fixed by the contract, and the draw discipline does not change. Enabling duplication with this setter when it was never configured at construction begins drawing from duplication's own independent stream from that point on, without shifting any other fault kind's sequence.

func (*Network) SetLatency added in v0.2.0

func (n *Network) SetLatency(min, max time.Duration)

SetLatency changes the latency applied to every connection in n, including connections already established — the same live semantics Partition and Heal have. It is what makes "healthy, then degraded, then healthy" expressible without building a second Network, which would mean new connections and a reset of every ordinal.

The arguments mean exactly what WithLatency's do, and are validated the same way: min and max must be non-negative and min must not exceed max, or the call panics naming the offending value. SetLatency(0, 0) is an explicit fixed-zero delay rather than "off" — it still draws, per the draw discipline below.

Determinism: a setter is an ordered Network call, joining Dial, Listen, Partition and Heal in the guarantee on WithSeed. What the contract does not fix is a setter's order against in-flight I/O on another goroutine — if one goroutine writes in a loop while another calls SetLatency, which unit first sees the new value is the scheduler's choice, not the seed's. Sequence the calls a test depends on: write, then set, then write.

Draw discipline is unchanged. Latency still draws on every unit past the partition gate, so changing the range changes the value a draw produces, never whether the draw happens — a stream advances one value per unit regardless of what any setter did.

func (*Network) SetPacketLoss added in v0.2.0

func (n *Network) SetPacketLoss(rate float64)

SetPacketLoss changes the packet-loss rate applied to every connection in n, including connections already established — the same live semantics Partition and Heal have.

rate means exactly what WithPacketLoss's does and is validated the same way: outside [0.0, 1.0], including NaN and ±Inf, panics naming the offending value. SetPacketLoss(0.0) is an explicit always-deliver policy rather than "off"; it still draws.

The same determinism note as SetLatency applies: the setter is an ordered Network call, its order against concurrent in-flight I/O is not fixed by the contract, and the draw discipline does not change.

Example

ExampleNetwork_SetPacketLoss shows SetLatency and SetPacketLoss changing an already-established connection's fault policy mid-test, rather than only affecting connections dialed afterward: the same client and server pair deliver, then drop, then deliver again as the policy changes around them.

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()

	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("first")); 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))

	n.SetPacketLoss(1.0)
	if _, err := client.Write([]byte("dropped")); err != nil {
		fmt.Println(err)
		return
	}

	n.SetPacketLoss(0.0)
	n.SetLatency(time.Millisecond, time.Millisecond)
	if _, err := client.Write([]byte("third")); err != nil {
		fmt.Println(err)
		return
	}
	buf2 := make([]byte, 5)
	if _, err := io.ReadFull(server, buf2); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(buf2))
}
Output:
first
third

func (*Network) Trace added in v0.2.0

func (n *Network) Trace() []FaultEvent

Trace returns every fault-injection decision recorded across every connection n has ever dialed, in (Ordinal, Side, Seq) order — the same canonical order the reproducibility harness (M3-3, reproducibility_test.go) compares golden traces in. The order holds however dials interleave, including connections dialed concurrently from many goroutines (#112). This closes the deferral M2-1 recorded when the trace was first built: always recorded, never exported, until M6-14 decided the full trace (not counters) belonged in v0.2.0's scope.

The returned slice is a copy: mutating it, or a later call to Trace, never affects the other. Reused from traceRecorder.snapshot, which already establishes this per direction (TestTraceSnapshotIsACopy); Trace copies no events of its own beyond concatenating those (it only sorts a copy of its per-direction handles).

See FaultEvent's own godoc for what is and is not recorded — notably, Network.Reset produces no event, and a dial that never establishes allocates no pipes to trace at all.

Example

ExampleNetwork_Trace shows the exported fault trace attributing exactly one event per Write unit: at rate 1.0 every write is dropped, and Trace reports as many dropped events as writes were made.

package main

import (
	"fmt"
	"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(3), netchaos.WithPacketLoss(1.0))

	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() }()

	const writes = 4
	for i := 0; i < writes; i++ {
		if _, err := client.Write([]byte{'A' + byte(i)}); err != nil {
			fmt.Println(err)
			return
		}
	}

	dropped := 0
	events := n.Trace()
	for _, ev := range events {
		if ev.Dropped {
			dropped++
		}
	}
	fmt.Printf("%d events, %d dropped\n", len(events), dropped)
}
Output:
4 events, 4 dropped

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 WithBandwidth added in v0.2.0

func WithBandwidth(bytesPerSecond int) Option

WithBandwidth throttles delivery to the given rate, in bytes per second, applied globally to every connection direction the Network handles (the same scoping as WithLatency and WithPacketLoss -- see M0-2). bytesPerSecond must be positive; NewNetwork panics otherwise, naming WithBandwidth and the offending value. Not configuring WithBandwidth means unlimited bandwidth, the same distinction WithLatency and WithPacketLoss draw between "never given" and an explicit degenerate value.

Unlike WithLatency and WithPacketLoss, the throttle is a deterministic function of a unit's size and the configured rate -- it draws nothing from any seeded stream, so it has no per-unit randomness to converge or reproduce, and enabling it can never perturb the loss or latency draw sequence on the same direction. See the package doc's fault-composition section for where this stage sits relative to the other three.

The rate is applied per connection direction, not shared across a connection's two directions or across connections: a full-duplex conn gets the configured rate each way, and dialing many connections dials many independently-throttled links, mirroring how latency and loss are already evaluated per direction.

This is a construction-time setting; there is no runtime setter. Unlike SetLatency, SetPacketLoss (M7-4), SetDuplication, and SetCorruption (M9-4), a configured rate cannot be changed on a live connection -- bandwidth is the one fault kind whose live-setter case was split out and deferred (issue #85, M8-7 finding F6): unlike the other four, it draws nothing, so a setter's interaction with the pipe's serialization clock (pipe.busyUntil) needs its own design pass rather than an assumption that it mirrors SetLatency. See docs/06 § Explicitly out of scope for v1.

Example

ExampleWithBandwidth shows a write delayed in proportion to its size and the configured rate, rather than dropped or reordered: the bytes arrive unchanged, just later than they would with no throttle configured. The rate here is fast enough that the delay costs the example no meaningful real time; netchaos does not require any particular magnitude.

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.WithBandwidth(1_000_000)) // 1 MB/s

	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 WithCorruption added in v0.2.0

func WithCorruption(rate float64) Option

WithCorruption flips a single bit, chosen uniformly at random, in a delivered Write unit, with the given probability, in [0.0, 1.0]. Applies globally, to every connection direction the Network handles. A rate outside [0.0, 1.0], including NaN and +/-Inf, makes NewNetwork panic, naming WithCorruption and the offending rate.

M7-9 (#53, candidate 4): real TCP hides corruption from the application -- the receiver's checksum drops a corrupt segment -- but netchaos already crossed that line deliberately for packet loss (M0-3: real TCP hides loss too, by retransmitting, and WithPacketLoss injects the silent gap anyway, precisely so a test can exercise what the application sees when the transport does not behave). Corruption is accepted on the same reasoning, the same way WithDuplication (M7-8) was.

The corrupted unit's length is unchanged -- only its content. Corrupting a length would model truncation, a different fault than this one; the fault unit stays the whole Write (M0-3).

The unit of corruption is the Write call, matching the fault unit WithPacketLoss and WithLatency already use (M0-3): a dropped unit is never corrupted, but the decision whether to corrupt it, and (if so) which bit, is still drawn -- see the draw discipline on installFaultPolicy (faults.go). A zero-length write has no bit to flip, so the decision is drawn but nothing is mutated.

The caller's original buffer is never mutated: conn.Write already copies it before the data reaches the pipe, so corruption mutates only that private copy.

This sets the value a Network starts with. Network.SetCorruption (M9-4) changes it mid-test, on connections that already exist as well as future ones -- the same live semantics SetLatency/SetPacketLoss already have.

Example

ExampleWithCorruption shows a delivered write's content altered without its length changing: at rate 1.0 every unit has exactly one bit flipped, so the received bytes are the same length as, but never equal to, what was sent.

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.WithCorruption(1.0))

	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() }()

	sent := []byte{0x00, 0x00, 0x00, 0x00}
	if _, err := client.Write(sent); err != nil {
		fmt.Println(err)
		return
	}

	got := make([]byte, len(sent))
	if _, err := io.ReadFull(server, got); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(len(got) == len(sent), string(got) != string(sent))
}
Output:
true true

func WithDuplication added in v0.2.0

func WithDuplication(rate float64) Option

WithDuplication admits a delivered Write unit a second time, with the given probability, in [0.0, 1.0]. Applies globally, to every connection direction the Network handles. A rate outside [0.0, 1.0], including NaN and +/-Inf, makes NewNetwork panic, naming WithDuplication and the offending rate.

M7-8 (#53, candidate 3): real TCP hides duplication from the application -- the receiver's stack dedupes -- but netchaos already crossed that line deliberately for packet loss (M0-3: real TCP hides loss too, by retransmitting, and WithPacketLoss injects the silent gap anyway, precisely so a test can exercise what the application sees when the transport does not behave). Duplication is accepted on the same reasoning.

The duplicate is delivered with the same timing decision as the original -- whatever WithLatency/WithBandwidth computed for the first copy applies to the second as well, rather than drawing an independent delay for it. Duplicating with a delay between copies is a delivery- timing model latency already owns, and is out of scope here.

The unit of duplication is the Write call, matching the fault unit WithPacketLoss and WithLatency already use (M0-3): a dropped unit is never duplicated, but the decision whether to duplicate is still drawn for it -- see the draw discipline on installFaultPolicy (faults.go).

A duplicated unit is a second, independent copy: it counts against the pipe's buffer bound like any other delivered bytes, and mutating one copy (e.g. WithCorruption, M7-9) never affects the other.

This sets the value a Network starts with. Network.SetDuplication (M9-4) changes it mid-test, on connections that already exist as well as future ones -- the same live semantics SetLatency/SetPacketLoss already have.

Example

ExampleWithDuplication shows a single Write's bytes delivered twice, in order, rather than merged into one longer delivery: at rate 1.0 every unit is duplicated, so the reader sees the payload back to back.

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.WithDuplication(1.0))

	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() }()

	if _, err := client.Write([]byte("ping")); err != nil {
		fmt.Println(err)
		return
	}
	_ = client.Close()

	got, err := io.ReadAll(server)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(got))
}
Output:
pingping

func WithLatency

func WithLatency(min, max time.Duration) Option

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.

This sets the value a Network starts with. Network.SetLatency changes it mid-test, on connections that already exist as well as future ones.

When other faults are also configured on the same connection direction, latency is evaluated after partition, packet loss, and bandwidth, and before corruption and duplication — 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 WithListenerBacklog added in v0.2.0

func WithListenerBacklog(backlog int) Option

WithListenerBacklog sets the accept-queue bound for every listener n creates, in place of the default (listenerBacklog, 128). A Dial that finds a listener's queue already at this bound fails immediately with ErrBacklogFull rather than blocking for room (M6-17, #52) — see Network.DialContext.

backlog must be positive; NewNetwork panics otherwise, naming WithListenerBacklog and the offending value.

Example

ExampleWithListenerBacklog shows a Dial failing with ErrBacklogFull once a listener's accept queue reaches the configured bound, rather than the package default of 128.

package main

import (
	"errors"
	"fmt"

	"github.com/jpgomesr/netchaos"
)

func main() {
	n := netchaos.NewNetwork(netchaos.WithListenerBacklog(1))

	l, err := n.Listen("tcp", "server")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = l.Close() }()

	// Fills the one-slot backlog; nothing calls Accept to drain it.
	if _, err := n.Dial("tcp", "server"); err != nil {
		fmt.Println(err)
		return
	}

	_, err = n.Dial("tcp", "server")
	fmt.Println(errors.Is(err, netchaos.ErrBacklogFull))
}
Output:
true

func WithPacketLoss

func WithPacketLoss(rate float64) Option

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.

This sets the value a Network starts with. Network.SetPacketLoss changes it mid-test, on connections that already exist as well as future ones.

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

func WithPartition(peerA, peerB string) Option

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 WithPipeBound added in v0.2.0

func WithPipeBound(bound int) Option

WithPipeBound sets the per-connection-direction buffer bound applied to every connection n handles, in place of the default (defaultPipeBound, 64 KiB). The bound is what decides when a Write blocks on back-pressure: once a direction's buffered-but-unread bytes would exceed it, the next Write blocks until a Read frees enough room (M6-17, #52) — the same mechanism newConnPairWithBound (conn.go) already varied from in-package tests, now reachable from outside the package.

One write is always admitted whole even if it exceeds the bound: a fault unit is a whole Write (M0-3), never split, so a payload larger than the bound is accepted only into a direction currently holding no buffered bytes, and that single write's bytes may then sit above the configured bound until read. Nothing else can be admitted alongside it until the buffer drains back to (or below) the bound.

bound must be positive; NewNetwork panics otherwise, naming WithPipeBound and the offending value.

Example

ExampleWithPipeBound shows a Write blocking on back-pressure once a connection direction's buffered-but-unread bytes reach the configured bound, and unblocking once a Read frees enough room -- a hard bound, not a race, so the blocked/unblocked sequence below is deterministic.

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() {
	const bound = 4
	n := netchaos.NewNetwork(netchaos.WithPipeBound(bound))

	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() }()

	// Fills the bound exactly; must not block.
	if _, err := client.Write([]byte("ping")); err != nil {
		fmt.Println(err)
		return
	}

	// The direction is already at its bound, so this write blocks until a
	// Read frees room.
	blocked := make(chan struct{})
	go func() {
		_, _ = client.Write([]byte{'!'})
		close(blocked)
	}()

	select {
	case <-blocked:
		fmt.Println("wrote without blocking")
	case <-time.After(20 * time.Millisecond):
		fmt.Println("blocked until read")
	}

	buf := make([]byte, bound)
	if _, err := io.ReadFull(server, buf); err != nil {
		fmt.Println(err)
		return
	}

	select {
	case <-blocked:
		fmt.Println("unblocked after read")
	case <-time.After(time.Second):
		fmt.Println("still blocked")
	}
}
Output:
blocked until read
unblocked after read

func WithSeed

func WithSeed(seed int64) Option

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, Heal, SetLatency, and SetPacketLoss 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 around them. Two cases, one fix. If two goroutines race to Dial, which one is assigned which connection ordinal — and therefore which RNG stream — is decided by the Go scheduler, not the seed. And if a test calls SetLatency or SetPacketLoss from one goroutine while another is writing, which unit is the first to see the new value is likewise the scheduler's choice, not the seed's: the contract fixes a setter's order against other Network calls, not against in-flight I/O. Sequence the calls a test depends on — dial before starting concurrent I/O, and write, then set, then write — rather than expecting netchaos to pick a boundary.

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

type Side added in v0.2.0

type Side int

Side identifies which end of a connection a FaultEvent belongs to. It mirrors connSide (conn.go) as an exported value.

The two enums must agree value-for-value: the assertion below fails the build if either const block is ever reordered, rather than silently mislabeling every exported event's side — the same "exactly one place" caution addr.go's type comment carries for peer identity, applied here.

const (
	SideDialer Side = iota
	SideAcceptor
)

The two Side values, dialer and acceptor -- see the Side doc comment above for what each means and the ordering guarantee they must keep.

func (Side) String added in v0.2.0

func (s Side) String() string

String reports "dialer" or "acceptor", used in test failure output.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL