Documentation
¶
Overview ¶
Package faultline runs deterministic simulation tests inside go test. Run turns one test into many runs of a simulated world, one per seed. Each run builds nodes on a simulated network and disk, injects faults, and checks the invariants the test registers. Every random choice comes from the seed, so a failing seed replays exactly, as often as it takes to find and fix the bug.
A test passes Run its options and a function that builds one world. In the following test, a client pings a server every 100 ms while a fault planner crashes the server at random times. The final check asks for pongs after the faults stop:
var pingOptions = faultline.Options{Seeds: 50}
func TestPing(t *testing.T) {
faultline.Run(t, pingOptions, pingWorld)
}
// pingWorld builds one world. Run calls it once for every run.
func pingWorld(w *faultline.World) {
srv := w.AddServer("n1", func(n *kernel.Node) {
w.Net.Handle(n, func(from kernel.NodeID, _ any) { w.Net.Send(n, from, "pong") })
})
var lastPong kernel.Time
w.AddClient("c1", func(n *kernel.Node) {
w.Net.Handle(n, func(kernel.NodeID, any) { lastPong = w.Sim.Now() })
var ping func()
ping = func() {
w.Net.Send(n, srv.ID(), "ping")
n.After(100*time.Millisecond, "ping", ping)
}
n.Post("ping", ping)
})
w.Plan(&fault.Random{MaxDown: 1, Rules: []fault.Rule{
{Kind: fault.KindCrash, Every: time.Second, MaxFor: 500 * time.Millisecond},
}})
w.Final("pongs after recovery", func() error {
if lastPong < w.RecoveryStart() {
return fmt.Errorf("last pong at %s, recovery started at %s", lastPong, w.RecoveryStart())
}
return nil
})
}
Code inside the world runs as callbacks on one goroutine, in virtual time. It must take time from the kernel (Node.Now, Node.After) and randomness from World.Rand or Node.Rand, and it must not start goroutines. A node's BootFunc runs again at every restart, so a node rebuilds its state there, from its disk if it has one. Packages kernel, simnet, simdisk and fault describe the event loop, the network, the disks and the faults.
Each seed runs as a subtest named seed=0x followed by 16 hex digits. By default the seeds come from the test's name (NameBase, DeriveSeed), so they are the same on every machine. When a seed fails, Run prints the failure and a command that replays that seed. With a server that stops answering after its first restart, TestPing fails like this:
--- FAIL: TestPing/seed=0x0fa36865fb584320 (0.02s)
faultline: final check "pongs after recovery" failed after the run at t=60.000000000s
last pong at 1.305834063s, recovery started at 45.000000000s
replay: FAULTLINE_SEED=0x0fa36865fb584320 go test -v -run '^TestPing$' example.com/ping
The line after the replay command names the artifact directory, by default under faultline-<uid> in os.TempDir(). Its files include report.txt (the lines above), trace.jsonl (every event of the run), schedule.json (the faults) and timeline.html (a page that shows the run). Package artifact describes them all. The replay command runs the seed again and gets the same run, record for record. That holds as long as the faultline version, the Go minor version, the code and the options stay the same.
To keep a seed as a regression test, run it with RunSeeds in a test of its own. RunSeeds runs only the seeds it lists. The following test gives it the options and world function of TestPing:
func TestPingSeed0fa36865(t *testing.T) {
faultline.RunSeeds(t, pingOptions, pingWorld, 0x0fa36865fb584320)
}
Run's documentation lists the environment variables that choose seeds, replay a schedule and turn on the determinism check. The guides and reference pages are at https://github.com/hmdsefi/faultline/tree/main/docs.
Index ¶
- Constants
- func DeriveSeed(base uint64, i int) uint64
- func NameBase(testName string) uint64
- func Run(t *testing.T, opts Options, body func(w *World))
- func RunSeeds(t *testing.T, opts Options, body func(w *World), seeds ...uint64)
- type Mode
- type Options
- type World
- func (w *World) AddClient(name string, boot kernel.BootFunc, opts ...kernel.NodeOption) *kernel.Node
- func (w *World) AddServer(name string, boot kernel.BootFunc, opts ...kernel.NodeOption) *kernel.Node
- func (w *World) Clients() []*kernel.Node
- func (w *World) End() kernel.Time
- func (w *World) Final(name string, check func() error)
- func (w *World) Invariant(name string, check func() error)
- func (w *World) Logf(format string, args ...any)
- func (w *World) Options() Options
- func (w *World) Plan(p fault.Planner)
- func (w *World) Rand(label string) *rand.Rand
- func (w *World) RecoveryStart() kernel.Time
- func (w *World) Role(name string, fn func() []kernel.NodeID)
- func (w *World) RunFor(d time.Duration)
- func (w *World) Seed() uint64
- func (w *World) Servers() []*kernel.Node
- func (w *World) T() testing.TB
Examples ¶
Constants ¶
const ( DefaultSeeds = 20 // Options.Seeds == 0 DefaultDuration = 60 * time.Second // Options.Duration == 0 DefaultMaxEvents = 10_000_000 // Options.MaxEvents == 0 // ShortSeeds caps the seed count under `go test -short` unless FAULTLINE_SEEDS is set. ShortSeeds = 5 // MaxSeeds is the largest value FAULTLINE_SEEDS and Options.Seeds accept. MaxSeeds = 1_000_000 )
Defaults that Run applies to zero-valued Options fields.
Variables ¶
This section is empty.
Functions ¶
func DeriveSeed ¶
DeriveSeed returns seed i (0-based) of the seed list derived from base with SplitMix64. It panics if i < 0.
Example ¶
The seed list of a test depends only on its name, so the subtests of TestKV are the same on every machine. This prints the names of its first three seed subtests.
package main
import (
"fmt"
"github.com/hmdsefi/faultline"
)
func main() {
base := faultline.NameBase("TestKV")
for i := range 3 {
fmt.Printf("TestKV/seed=0x%016x\n", faultline.DeriveSeed(base, i))
}
}
Output: TestKV/seed=0x287372ab06f1482e TestKV/seed=0xac1b1f508c3fd743 TestKV/seed=0x9cb5a1e8588ac16e
func NameBase ¶
NameBase returns the default base seed for a test name: FNV-1a 64 of the bytes of testName.
func Run ¶
Run runs body for each seed of the seed list, one seed after another, each seed in its own subtest named "seed=0x%016x". Call it from the test goroutine of t, at most once per t.
Run calls body for every run of a seed, so body must build all the state it uses from w. A failing seed runs a second time to write its artifacts, and with determinism checking a passing seed runs twice. The runs of a seed must do the same thing.
A failing seed fails its subtest. It prints the failure, a replay command and its artifact directory, which holds report.json, report.txt, trace.jsonl, timeline.html and the other files of package artifact. The first failing seed stops the loop unless Options.KeepGoing is set or FAULTLINE_SEED_LIST names the seeds.
These environment variables change a run without code changes:
- FAULTLINE_SEED runs one seed. FAULTLINE_SEEDS, FAULTLINE_BASE_SEED and FAULTLINE_EXPLORE choose the seed list.
- FAULTLINE_SCHEDULE replays a schedule.json from an artifact.
- FAULTLINE_ARTIFACTS sets the artifact root, faultline-<uid> under os.TempDir() by default (faultline on Windows), or turns artifacts off with "off" in any letter case.
- FAULTLINE_CHECK_DETERMINISM=1 runs every passing seed twice and compares the trace hashes.
- FAULTLINE_TRACE=full keeps full traces and writes artifacts for passing seeds too, but a pass artifact replaces only another pass artifact.
- FAULTLINE_SEED_LIST runs exactly the listed seeds, and FAULTLINE_RESULTS appends one JSON line per seed to a file.
An invalid value fails t before any seed runs.
Example ¶
A client pings a server every 100 ms while a fault planner crashes the server at random times. The final check asks for pongs after the faults stop. TestPing is an ordinary test function; this example compiles it but does not run it, since Run needs the *testing.T of a real test.
package main
import (
"fmt"
"testing"
"time"
"github.com/hmdsefi/faultline"
"github.com/hmdsefi/faultline/kernel"
"github.com/hmdsefi/faultline/kernel/fault"
)
func main() {
// pingWorld builds one world. Run calls it once for every run of every seed.
pingWorld := func(w *faultline.World) {
// The server answers every ping. Its boot function runs again after each restart.
srv := w.AddServer("n1", func(n *kernel.Node) {
w.Net.Handle(n, func(from kernel.NodeID, _ any) { w.Net.Send(n, from, "pong") })
})
// The client sends a ping every 100 ms and notes when the last pong arrived.
var lastPong kernel.Time
w.AddClient("c1", func(n *kernel.Node) {
w.Net.Handle(n, func(kernel.NodeID, any) { lastPong = w.Sim.Now() })
var ping func()
ping = func() {
w.Net.Send(n, srv.ID(), "ping")
n.After(100*time.Millisecond, "ping", ping)
}
n.Post("ping", ping)
})
// About once a second, crash the server for up to 500 ms.
w.Plan(&fault.Random{MaxDown: 1, Rules: []fault.Rule{
{Kind: fault.KindCrash, Every: time.Second, MaxFor: 500 * time.Millisecond},
}})
// After the run, check that pongs came back once the faults stopped.
w.Final("pongs after recovery", func() error {
if lastPong < w.RecoveryStart() {
return fmt.Errorf("last pong at %s, recovery started at %s", lastPong, w.RecoveryStart())
}
return nil
})
}
TestPing := func(t *testing.T) {
faultline.Run(t, faultline.Options{Seeds: 50}, pingWorld)
}
_ = TestPing
}
Output:
func RunSeeds ¶ added in v0.1.1
RunSeeds is Run for a fixed list of seeds, such as a regression test for a seed that failed. It runs exactly the listed seeds, in the order given, each in its own subtest named "seed=0x%016x". It runs all of them, even after one fails. No seeds, or a seed listed twice, fails t before any seed runs. As with Run, call it from the test goroutine of t, and call Run or RunSeeds at most once per t.
RunSeeds ignores Options.Seeds and Options.BaseSeed, and the variables that choose seeds: FAULTLINE_SEED, FAULTLINE_SEEDS, FAULTLINE_BASE_SEED, FAULTLINE_EXPLORE and FAULTLINE_SEED_LIST. It logs the ones that are set, and an invalid value still fails t. Every other option and variable works as in Run.
The replay command of a failing seed selects the seed's subtest with -run instead of setting FAULTLINE_SEED:
go test -v -run '^TestKVSeed287372ab$/^seed=0x287372ab06f1482e$' example.com/kv
Like Run, RunSeeds seeds crypto/rand for every run, which Go does not allow in a parallel test. A test that calls RunSeeds can call t.Parallel only with Options.NoCryptoSeed set.
Example ¶
Seed 0x0fa36865fb584320 of TestPing failed, and the bug is fixed. TestPingSeed0fa36865 keeps that seed as a regression test: it runs only that seed, with the options and the world function of TestPing, and fails again if the bug comes back. Like ExampleRun, this example compiles the test but does not run it.
package main
import (
"testing"
"github.com/hmdsefi/faultline"
)
func main() {
pingOptions := faultline.Options{Seeds: 50}
// pingWorld builds one world, as in the example of Run.
pingWorld := func(w *faultline.World) {
// ...
}
TestPing := func(t *testing.T) {
faultline.Run(t, pingOptions, pingWorld)
}
TestPingSeed0fa36865 := func(t *testing.T) {
faultline.RunSeeds(t, pingOptions, pingWorld, 0x0fa36865fb584320)
}
_, _ = TestPing, TestPingSeed0fa36865
}
Output:
Types ¶
type Mode ¶
type Mode uint8
Mode selects how code under test runs.
const ( // ModeEvent runs code written as kernel callbacks (boot functions, timers, network handlers) // on the kernel's event loop. A run is exactly reproducible from its seed. ModeEvent Mode = iota // ModeGoroutine is reserved for running goroutine code inside a testing/synctest bubble. Run // rejects it with a setup error. ModeGoroutine )
type Options ¶
type Options struct {
// Seeds is the number of seeds to run. 0 means DefaultSeeds. Negative or more than
// MaxSeeds is a setup error.
// FAULTLINE_SEEDS overrides it; FAULTLINE_SEED and FAULTLINE_SEED_LIST replace the seed list.
// RunSeeds ignores it.
Seeds int
// BaseSeed is the base from which the seed list is derived. 0 means FNV-1a 64 of t.Name().
// FAULTLINE_BASE_SEED and FAULTLINE_EXPLORE override it. RunSeeds ignores it.
BaseSeed uint64
// Duration is the virtual length of each run. 0 means DefaultDuration. Negative is a setup error.
Duration time.Duration
// MaxEvents stops a run that executes more events (kind "limit" unless AllowLimit).
// 0 means DefaultMaxEvents. math.MaxUint64 means no practical limit.
MaxEvents uint64
// Mode selects how code under test runs. ModeEvent, the zero value, is the only mode Run
// accepts.
Mode Mode
// Net configures the simulated network. The zero value means simnet.DefaultConfig(), so a
// network with no latency at all cannot be configured here. The closest is a config with one
// non-zero field, for example Default.Latency = 1, which gives every message 1 ns of latency.
Net simnet.Config
// Disk configures the simulated volumes and is passed to simdisk.New unchanged. The zero
// value behaves like simdisk.DefaultConfig().
Disk simdisk.Config
// Trace configures the first run of every seed. The zero value is TraceHash.
// FAULTLINE_TRACE=full upgrades it to TraceFull and writes artifacts for passing seeds.
Trace kernel.TraceConfig
// CheckDeterminism runs every passing seed a second time and compares trace hashes.
// FAULTLINE_CHECK_DETERMINISM can enable it but not disable it.
CheckDeterminism bool
// KeepGoing runs all seeds even after one fails. Without it, Run stops starting new
// seeds after the first failing seed. Forced on by FAULTLINE_SEED_LIST and by RunSeeds.
KeepGoing bool
// NoCryptoSeed stops Run from seeding crypto/rand with cryptotest.SetGlobalRandom. crypto/rand
// in code under test is then not deterministic. In exchange, the test may call t.Parallel,
// which cryptotest.SetGlobalRandom forbids.
NoCryptoSeed bool
// AllowLimit makes hitting MaxEvents (or the kernel's MaxTime) a normal end of the run
// instead of a failure. Final checks are skipped for such a run.
AllowLimit bool
// Procs is reserved for goroutine mode: GOMAXPROCS during its runs. Run rejects a non-zero
// value with a setup error.
Procs int
// Drain is reserved for goroutine mode: the virtual time processes get after the run ends to
// exit on their own. Run rejects a non-zero value with a setup error.
Drain time.Duration
// StallTimeout is reserved for goroutine mode: the wall-clock time without progress after
// which a watchdog ends the test binary with a report. Run rejects a non-zero value with a
// setup error.
StallTimeout time.Duration
// FailOnLeak is reserved for goroutine mode: it makes goroutines still alive after the drain
// a failure. Run rejects true with a setup error.
FailOnLeak bool
// Swarm is reserved for swarm testing, where each seed also picks which fault kinds are on.
// Run rejects true with a setup error.
Swarm bool
}
Options configures Run and RunSeeds. The zero value is valid: 20 seeds of 60 s virtual time each, default network, default disks, hash-only tracing.
type World ¶
type World struct {
Sim *kernel.Sim // the event loop, virtual time and the trace
Net *simnet.Network // the network, created from Options.Net
Disk *simdisk.Disks // the volumes, created from Options.Disk
Faults *fault.Injector // applies faults and records them for schedule.json
History *history.Recorder // client operations, written to history.jsonl
// contains filtered or unexported fields
}
World is one simulated world, built for one run of one seed. Run builds a new World for every run, and the function passed to Run adds nodes, plans faults and registers checks on it. Its methods must be called from the seed subtest's goroutine only.
func (*World) AddClient ¶
func (w *World) AddClient(name string, boot kernel.BootFunc, opts ...kernel.NodeOption) *kernel.Node
AddClient adds a node tagged "client". It equals w.Sim.AddNode(name, boot, append(opts, kernel.WithTags("client"))...).
func (*World) AddServer ¶
func (w *World) AddServer(name string, boot kernel.BootFunc, opts ...kernel.NodeOption) *kernel.Node
AddServer adds a node tagged "server". It equals w.Sim.AddNode(name, boot, append(opts, kernel.WithTags("server"))...).
func (*World) End ¶
End returns the virtual time at which the run ends: the replayed schedule's End when non-zero, else kernel.Time(Options.Duration).
func (*World) Final ¶
Final registers a check that runs once after the run ends normally. All final checks run, in registration order; the first failing one is the seed's failure (kind "final").
func (*World) Invariant ¶
Invariant registers a check that runs after every executed event. It must be cheap and must not change the world. The first non-nil error fails the run (kind "invariant").
func (*World) Logf ¶
Logf records a global kernel.log record (w.Sim.Logf) and, in the seed's first run only, logs the message with t.Logf prefixed by the virtual time.
func (*World) Options ¶
Options returns the effective options of this Run or RunSeeds call: defaults applied, environment overrides applied, Seeds set to the length of the seed list, BaseSeed set to the resolved base (0 when the list came from FAULTLINE_SEED, FAULTLINE_SEED_LIST or RunSeeds), Trace set to the config of the seed's first run. It returns the same value in every run.
func (*World) Plan ¶
Plan registers a fault planner. Planners start when the simulation first advances (the first World.RunFor, or after body returns). Under FAULTLINE_SCHEDULE no planner is started: the replayed schedule is the only source of faults.
func (*World) RecoveryStart ¶
RecoveryStart returns the start of the recovery window, where fault.Random planners end the faults they started and start no more. It is the replayed schedule's Recovery when non-zero, else the earliest Until of the registered *fault.Random planners, else End(). A planner's Until is End minus its Quiet, a quarter of the run by default. Final checks use RecoveryStart to ask whether the system recovered.
func (*World) Role ¶
Role registers a named node selector that planners can target (fault.Rule.Target).
func (*World) RunFor ¶
RunFor advances the simulation by d virtual time. It may only be called from body. If the run fails or hits a limit, RunFor does not return: body is stopped and Run continues with failure handling. Recovering that stop does not resume the run: every later RunFor, also a deferred one, stops body again.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package artifact reads and writes faultline artifact directories: report.json, report.txt, trace.jsonl, schedule.json, history.jsonl, extra files, and the render files timeline.txt, hb.mmd and timeline.html.
|
Package artifact reads and writes faultline artifact directories: report.json, report.txt, trace.jsonl, schedule.json, history.jsonl, extra files, and the render files timeline.txt, hb.mmd and timeline.html. |
|
check
|
|
|
history
Package history records the client operations of a simulation run: invocations, completions, virtual times and their exact order.
|
Package history records the client operations of a simulation run: invocations, completions, virtual times and their exact order. |
|
cmd
|
|
|
faultline
command
The faultline command works on the artifact directory that a failing faultline test writes for one seed.
|
The faultline command works on the artifact directory that a failing faultline test writes for one seed. |
|
harness
|
|
|
etcdraft
module
|
|
|
internal
|
|
|
detlint
Package detlint is faultline's determinism lint, import-boundary check and exported-API snapshot generator.
|
Package detlint is faultline's determinism lint, import-boundary check and exported-API snapshot generator. |
|
golden
Package golden holds the run-twice determinism helper for tests that drive a kernel.Sim directly.
|
Package golden holds the run-twice determinism helper for tests that drive a kernel.Sim directly. |
|
toys
Package toys holds tiny event-style systems with exactly specified behavior.
|
Package toys holds tiny event-style systems with exactly specified behavior. |
|
Package kernel is the simulator under faultline: a single-threaded event loop with virtual time and a seeded scheduler.
|
Package kernel is the simulator under faultline: a single-threaded event loop with virtual time and a seeded scheduler. |
|
fault
Package fault describes faults as data (events and schedules), applies them to a simulated world (Injector), and decides them during a run (planners).
|
Package fault describes faults as data (events and schedules), applies them to a simulated world (Injector), and decides them during a run (planners). |
|
simdisk
Package simdisk simulates one disk volume per node, with the crash behavior of real file systems.
|
Package simdisk simulates one disk volume per node, with the crash behavior of real file systems. |
|
simnet
Package simnet simulates a datagram network between kernel nodes: per-link latency, jitter, tail latency, loss, duplication and FIFO order, and a gograph topology with partitions.
|
Package simnet simulates a datagram network between kernel nodes: per-link latency, jitter, tail latency, loss, duplication and FIFO order, and a gograph topology with partitions. |
|
Package ui embeds faultline's web assets: the run view that every timeline.html inlines, so the page works offline as a single file.
|
Package ui embeds faultline's web assets: the run view that every timeline.html inlines, so the page works offline as a single file. |
