merilo

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

merilo

merilo is for building state machines in Go that your tests verify exhaustively. Every machine imports code.eitanity.com/merilo/fsm for the parts all machines share, so each one is built the same way and only its own transitions are left to write. A world is one snapshot of the system the machines form: every machine and its surroundings, such as messages in flight. In your tests, merilo checks every world the system can reach and fuzzes beyond them. merilovet keeps your code within the rules those tests rely on.

fsm is the only part of merilo compiled into your program: it imports nothing, allocates nothing and never panics. merilo needs Go 1.22 or later and only the standard library.

Build a machine

This walks through examples/turnstile and examples/order.

1. Import fsm and name the states and events
import "code.eitanity.com/merilo/fsm"

fsm gives every machine the same fsm.Outcome: Moved took effect, by changing the machine or returning actions, Ignored and Rejected left it as it was, and Invalid refuses something impossible, such as a machine never built with New.

Your states and events are enums: a named integer type with its own constants. The zero value of each is an unexported sentinel, so a value nobody set deliberately matches no case and is refused.

type State uint8

const (stateNone State = iota; Locked; Unlocked; numStates)

type Event uint8

const (eventNone Event = iota; EvCoin; EvPush; numEvents)
2. Write the machine and its Fire

A machine is a value type with unexported fields. New builds it, State() reads it, and Fire is the only way it changes: it returns the next machine and the outcome.

type Machine struct{ state State }

func New() Machine             { return Machine{state: Locked} }
func (m Machine) State() State { return m.state }

func (m Machine) Fire(e Event) (Machine, fsm.Outcome) {
	switch m.state {
	case Locked:
		switch e {
		case EvCoin:
			m.state = Unlocked
			return m, fsm.Moved
		case EvPush:
			return m, fsm.Rejected
		default:
			return m, fsm.Invalid
		}
	case Unlocked:
		// ...
	default:
		return m, fsm.Invalid
	}
}

Write every switch on an enum with every constant and a default that returns fsm.Invalid, so a new state or event cannot slip through. Nothing panics. Keep the fields to values: no pointers, slices or maps. The machine is then comparable, and == tells two apart.

3. Hold it

The code that owns a machine replaces it with its own Fire result, in place. out.Err() is nil for Moved and Ignored and an fsm.Refusal for anything else, so every holder handles outcomes the same way:

c.m, acts, out = c.m.Fire(e)
if err := out.Err(); err != nil {
	return fmt.Errorf("%v in %v: %w", e, c.m.State(), err)
}

Never copy a machine or take its address. A copy written back later would revert it without a transition.

4. Test that every pair is handled

An internal test lists machines and events, with position 0 the zero value of each. Total fires every event at every machine. Position 0 of either must be refused as Invalid. Any other pair fails if it is Invalid, or if it is Ignored or Rejected but still changed the machine:

type table struct{}

func (table) Machines() int { return int(numStates) }
func (table) Events() int   { return int(numEvents) }

func (table) Fire(m, e int) (fsm.Outcome, bool) {
	before := Machine{state: State(m)}
	next, out := before.Fire(Event(e))
	return out, next == before
}

func (table) Name(m, e int) string { return fmt.Sprintf("state %d / event %d", m, e) }

func TestTotal(t *testing.T) { merilo.Total(t, table{}) }
5. Explore and fuzz the system

An adapter in a _test.go file puts the machines in their environment, such as the network, the timers and the user. Steps are numbered kinds:

type System interface {
	Reset()                // return to the initial world
	Kinds() int            // number of step kinds
	Enabled(k int) bool    // whether step kind k can be taken now
	Apply(k int) error     // take step kind k; non-nil error is a violation
	World() any            // the current world, a comparable value
	Check() error          // invariants on the current world
	Tuple() string         // every machine's control state, formatted
	Settled() bool         // stopped by design
	Describe(k int) string // one readable trace line
	Invariants() []string  // names of the checks Check performs
}

Apply drives the machines through Fire exactly as production code does. World usually returns the adapter itself, return *s: its machines, and its model of the environment in fixed arrays.

Machines that are completely separate, such as an app's business logic and its user authentication, are separate systems: give each its own adapter, and its own Explore, Fuzz and Total, in its own package's tests.

var opts = merilo.Options{MaxStates: 1000, RequireComplete: true}

func TestExplore(t *testing.T) { merilo.Explore(t, newSys(), opts) }

func FuzzTurnstile(f *testing.F) {
	merilo.Fuzz(f, newSys(), merilo.FuzzOptions{MaxDepth: 10, Explore: opts})
}

Explore is a breadth-first search, so the first violation it finds has the shortest trace. From each world it replays the path from Reset, takes every enabled kind and checks the invariants. A replay that reaches a different world means something outside the path, such as the clock, leaked in. It reports:

  • invariant violations;
  • deadlocks: no step is enabled and Settled is false;
  • apply errors and undeclared mode changes;
  • nondeterminism.

MaxStates, the most worlds it stores, is its one bound and has no default. It stops at the first world that doesn't fit, and every run logs what it proved: complete=true means every reachable world was checked, and otherwise every world within depth steps of the start was.

merilo: complete=true states=38 depth=6 invariants=1 [no one passes without paying]

Under go test -fuzz, Fuzz runs that search first. For each combination of machine states the search reached, it takes the shortest list of steps that gets there, and starts fuzzing from it. So fuzzing goes further than the search. MaxDepth is the most steps one fuzz input takes. The search runs once, in the coordinating process, not in each fuzz worker. A plain go test skips it, since Explore's own test has already run that search.

6. Keep each counterexample

A violation fails the test with a readable trace:

#05 deliver n0->n1 Submit   | n0:Pending n1:Pending
#06 drop    n1->n0 Ack      | n0:Pending n1:Pending
#07 tick    n0              | n0:Failed  n1:Pending
VIOLATION invariant "no failure while peer pending" at step 7

Copy its kinds into a test that calls merilo.Replay, which fails while the bug is there and when the trace no longer fits the system.

7. Run merilovet
go run code.eitanity.com/merilo/cmd/merilovet

It checks your packages and their tests against the rules above:

  • every switch is complete, including those on fsm.Outcome;
  • fields are written only through Fire;
  • machines are never copied or pointed to;
  • every outcome is read;
  • counters are 64-bit and bounded;
  • every World returns a value == can compare.

It finds machines from their types, with no markers and no suppressions. It reads the files one target builds, so run it once for each GOOS/GOARCH you ship, and name your build tags for code behind a constraint such as //go:build tinygo:

GOOS=linux GOARCH=arm go run code.eitanity.com/merilo/cmd/merilovet -tags tinygo,baremetal

merilovet -h prints its help and exit codes. merilovet -version prints the git tag it was built from, or a pseudo-version naming the commit when built between tags.

Patterns

  • Side effects are actions. Fire returns what the holder must do as a third result, Fire(E) (Machine, Actions, fsm.Outcome), and the holder does it. Fire itself does no I/O, so replay can check it. Actions is a bit set of the machine's own type, so one machine's actions cannot be handed to another's holder, and the holder performs them in declaration order:

    type Actions uint64
    
    const (ActSend Actions = 1 << iota; ActResend; ActStartTimer; ActCancelTimer)
    
    for a := order.Actions(1); a != 0; a <<= 1 {
    	if acts&a != 0 {
    		c.do(a)
    	}
    }
    
  • Counters are fsm.Counter. A counter has a limit set when it is built, and Inc refuses at it instead of wrapping:

    case EvTimeout:
    	retries, ok := m.retries.Inc()
    	if !ok {
    		m.state = Failed
    		return m, 0, fsm.Moved
    	}
    	m.retries = retries
    	return m, ActResend | ActStartTimer, fsm.Moved
    
  • Timers are events. The machine asks with ActStartTimer and ActCancelTimer. The holder owns the time.Timer and fires EvTimeout when it expires. A stopped timer can still deliver a late expiry. If timers only stop as the machine leaves the waiting state, that state ignores it (examples/order). If a running timer can be replaced, carry a generation in the event so Fire ignores the stale one (examples/watchdog).

  • Any other number in a machine is int64 or uint64, compared with its bound before each change and refused at it, as the watchdog's generation is. An int wraps at 2³¹ on 32-bit targets.

  • Modes derive from state. Mode() is a switch on the state. Fire refuses an undeclared change of mode and reports a permitted one as an action.

  • Steps with an argument get one kind each. Delivering the message in slot i is kind kDeliver+i. Keep messages sorted in a fixed array, so the order they were sent in doesn't split one world into many (examples/order).

Examples

Example Shows
examples/turnstile The smallest machine: one mode, no actions
examples/order Payment order with retries, modes, a timer and a lossy, duplicating network; cmd/orderd is a holder
examples/mutex Lock clients against an idempotent server, with a seeded duplicate-grant bug
examples/twophase Two-phase commit coordinator with voters, recovery modes and two seeded bugs
examples/watchdog Timeout events that carry a generation, so Fire itself drops a stale timeout
examples/fib Tail recursion: the machine is the call fib(n, a, b) and holds all three arguments. Each Fire evaluates it once: when n is 0 the call returns a, and otherwise the machine makes the tail call by becoming fib(n-1, b, a+b), refusing as TooLarge a sum that would not fit in 1024 bits. A big.Int holds a slice, so == could not compare a machine that held one. The machine holds each number as 16 words of 64 bits, adds them with carry, and returns them as big.Int. The holder's loop is the trampoline: it fires until the call returns
examples/bom A bill of materials rolled up by tail recursion: each part's subtotal is its value plus each line's quantity times that part's subtotal, and the system's need of each part is the quantities multiplied down every path. The machine is the call walk(stack, need, subtotal): it holds the bill, the stack of work still to do and the numbers, and each Fire visits the top entry, which is the tail call. It refuses a part on the path to itself, numbers that would not fit in 64 bits, and more calls than its limit; the holder in bom/rollup only names the parts and loops until the call returns

The adapters in examples/ are meant to be copied.

What merilo promises

  • No files, network, processes, environment or global state. Fuzz inputs stay in Go's own testdata/fuzz. The one outside thing merilo reads is the testing package's own -test.fuzz and -test.fuzzworker flags, read-only, so that Fuzz searches only when it is coordinating.
  • fsm, the part in your program, imports nothing, allocates nothing and never panics.
  • No panics from merilo. An error is returned or reported through testing.TB, and merilovet exits 2 on its own faults. A World that == cannot compare still panics inside Go's map; merilovet refuses one.
  • Determinism. No randomness or wall-clock time, and no dependence on map order.
  • No allocation after setup, no silent overflow, and safe to run concurrently.
  • No recursion through your adapter. An adapter method that calls back into merilo is refused with an error.

Development

make ci     # verify build fmt vet lint fsm selfcheck race test-386 fuzz seeded cover vuln
make qemu   # the test suite under qemu-user on 11 more CPUs
Target Runs
verify go mod verify, a tidy go.mod with no require lines, no tracked binaries
lint golangci-lint, pinned and installed into bin/
fsm merilovet on this module, once per target in FSM_TARGETS
selfcheck Code safety and overflow self-check on merilo, fsm and cmd/merilovet
test-386 vet and tests with 32-bit int
qemu Tests on arm, arm64, loong64, mips, mipsle, mips64, mips64le, ppc64, ppc64le, riscv64 and s390x; needs qemu-user, and QEMU_TARGETS picks a subset
fuzz Every fuzz target for FUZZTIME (default 30s)
seeded Fuzzing seeded by a shallow search must find the seeded bug beyond it
vuln govulncheck

License

Copyright 2026 Eitanity Systems VCC. Licensed under the Apache License, Version 2.0.

Documentation

Overview

Package merilo explores and fuzzes systems of hand-written state machines through an adapter. It is imported only from _test.go files.

Index

Constants

View Source
const ErrModeChange = constError("merilo: undeclared system-mode change")

ErrModeChange is wrapped by an adapter's Apply when the system mode changes in a way the adapter does not allow.

Variables

This section is empty.

Functions

func Fuzz

func Fuzz(f *testing.F, sys System, o FuzzOptions)

Fuzz turns sys into a native Go fuzz target. Each input decodes into a schedule; a violation fails with the readable trace. With Explore set, the shortest schedule to each tuple the search reaches seeds the fuzzer, so fuzzing starts at the edge of the exhaustive search. The search runs once, in the process go test -fuzz coordinates from. A fuzz worker and a plain go test skip it: a worker takes its inputs from the coordinator, and a plain go test would only replay schedules a search with the same bound has passed, so it is Explore's test that checks them.

func Replay

func Replay(sys System, tr Trace) error

Replay resets sys and follows tr, checking invariants after each step. It fails at a step whose kind is not enabled.

func Total

func Total(t testing.TB, tab Totality)

Total fires every event at every machine. Position 0 of either must be refused as Invalid, with the machine unchanged; any other pair must not be Invalid, and a quiet pair must leave the machine unchanged with no actions.

Types

type FuzzOptions

type FuzzOptions struct {
	MaxDepth int     // steps per input, drain included
	Explore  Options // when set and fuzzing with -fuzz, a search with this bound runs first, as Explore does, and seeds the fuzzer
}

FuzzOptions bounds a fuzz run. MaxDepth has no default.

type Options

type Options struct {
	MaxStates       int  // the most worlds the search stores
	RequireComplete bool // stopping at MaxStates fails the test
}

Options bounds a search. MaxStates has no default.

type Result

type Result struct {
	Complete  bool     // every reachable world was checked
	States    int      // worlds stored
	Depth     int      // every world within this many steps of the start was checked
	Tuples    []string // control-state tuples reached, sorted
	Violation Violation
}

Result is what a search reached and the first violation, if any.

func Explore

func Explore(t testing.TB, sys System, o Options) Result

Explore runs a bounded breadth-first search of sys, logs a summary line, and fails t on a violation or, with RequireComplete, on truncation.

type System

type System interface {
	Reset()                // return to the initial world
	Kinds() int            // number of step kinds
	Enabled(k int) bool    // whether step kind k can be taken in the current world
	Apply(k int) error     // take step kind k; non-nil error is a violation
	World() any            // the current world as a strictly comparable value
	Check() error          // invariants on the current world
	Tuple() string         // every machine's control state, formatted
	Settled() bool         // stopped by design, including by an exhausted budget
	Describe(k int) string // one readable trace line for step kind k
	Invariants() []string  // names of the checks Check performs
}

System is the adapter between the harness and the adopter's machines. Steps are kinds, numbered 0 to Kinds()-1; the harness asks which are enabled one at a time, so nothing but integers crosses the interface.

type Totality

type Totality interface {
	Machines() int // machine values to check; position 0 is the zero machine
	Events() int   // events to fire; position 0 is the zero event
	// Fire builds machine m, fires event e, and returns the outcome and
	// whether the machine and its actions were unchanged.
	Fire(m, e int) (out fsm.Outcome, unchanged bool)
	Name(m, e int) string // a readable name for the pair
}

Totality exposes one machine's state–event table to Total by position. Positions are indexes the test chooses, never domain values.

type Trace

type Trace []int

Trace is the step kinds taken from the initial world, in order.

func (Trace) Format

func (tr Trace) Format(sys System, w io.Writer) error

Format replays tr, writing one line per step: its description and every machine's control state after it.

func (Trace) FuzzBytes

func (tr Trace) FuzzBytes(sys System) ([]byte, error)

FuzzBytes encodes tr as a fuzz input: an all-zero swarm mask, then each kind as an unsigned varint, which the fuzzer takes as it is whenever the kind is enabled.

type Violation

type Violation struct {
	Kind   ViolationKind
	Trace  Trace  // shortest path to the violating world
	Detail string // the error from Check or Apply
}

Violation is the first rule broken, with the shortest path to it.

func (Violation) Found

func (v Violation) Found() bool

Found reports whether v records a violation.

type ViolationKind

type ViolationKind uint8

ViolationKind classifies a violation. The zero value means none.

const (
	Invariant ViolationKind
	Deadlock
	ApplyError
	ModeChange
	Nondeterminism
)

Violation kinds.

func (ViolationKind) String

func (k ViolationKind) String() string

Directories

Path Synopsis
cmd
merilovet command
Command merilovet enforces merilo's FSM rules on every package of the main module.
Command merilovet enforces merilo's FSM rules on every package of the main module.
examples
bom
Package bom rolls up a bill of materials by tail recursion, as a state machine.
Package bom rolls up a bill of materials by tail recursion, as a state machine.
bom/rollup
Package rollup is the holder for package bom.
Package rollup is the holder for package bom.
fib
Package fib computes Fibonacci numbers by tail recursion, as a state machine:
Package fib computes Fibonacci numbers by tail recursion, as a state machine:
fib/cmd/fib command
Command fib prints the nth Fibonacci number.
Command fib prints the nth Fibonacci number.
mutex/client
Package client is one client of a lock server: it asks for the lock, holds it once granted, and releases it.
Package client is one client of a lock server: it asks for the lock, holds it once granted, and releases it.
order
Package order is a payment-order state machine: submit a charge to a provider, resend on timeout up to MaxRetries, and finish as Paid, Failed or Cancelled.
Package order is a payment-order state machine: submit a charge to a provider, resend on timeout up to MaxRetries, and finish as Paid, Failed or Cancelled.
order/cmd/orderd command
Command orderd drives one order from commands on standard input ("submit", "ack", "cancel"), with a real timer for timeouts.
Command orderd drives one order from commands on standard input ("submit", "ack", "cancel"), with a real timer for timeouts.
turnstile
Package turnstile is the smallest machine merilo checks: one mode, no actions, so Fire returns only the next machine and the outcome.
Package turnstile is the smallest machine merilo checks: one mode, no actions, so Fire returns only the next machine and the outcome.
twophase/coord
Package coord is a two-phase-commit coordinator for two participants.
Package coord is a two-phase-commit coordinator for two participants.
watchdog
Package watchdog fires when it is not kicked in time.
Package watchdog fires when it is not kicked in time.
Package fsm is what every merilo machine imports: the outcome Fire returns, the error a holder reports for a refused event, and the bounded counter a machine keeps instead of a raw integer.
Package fsm is what every merilo machine imports: the outcome Fire returns, the error a holder reports for a refused event, and the bounded counter a machine keeps instead of a raw integer.
internal
impure
Package impure is an acceptance-test fixture: a machine whose Fire consults a helper package's hidden state, so replaying the same path can reach a different world.
Package impure is an acceptance-test fixture: a machine whose Fire consults a helper package's hidden state, so replaying the same path can reach a different world.
impure/clock
Package clock is a helper with hidden state, standing in for a wall clock: each call returns a different value.
Package clock is a helper with hidden state, standing in for a wall clock: each call returns a different value.

Jump to

Keyboard shortcuts

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