netchaos

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 13 Imported by: 0

README

netchaos

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.1.0 released. v1 is implemented, tested, and documented. 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, and partitions. 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.

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.

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.1.0

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.

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, 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

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.

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

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.

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

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.

func (*Network) Listen

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

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

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.

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

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.

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

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.

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 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, 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

Jump to

Keyboard shortcuts

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