{"id":50405911,"url":"https://github.com/b-erdem/lockstep","last_synced_at":"2026-05-31T01:30:51.188Z","repository":{"id":357152167,"uuid":"1230344383","full_name":"b-erdem/lockstep","owner":"b-erdem","description":"Coyote-style controlled concurrency testing for the BEAM","archived":false,"fork":false,"pushed_at":"2026-05-08T15:19:27.000Z","size":554,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-11T16:06:39.567Z","etag":null,"topics":["beam","concurrency","concurrency-testing","coyote","elixir","erlang","linearizability","pct","race-condition","testing"],"latest_commit_sha":null,"homepage":"https://hexdocs.pm/lockstep","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/b-erdem.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-05T22:56:23.000Z","updated_at":"2026-05-08T16:28:42.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/b-erdem/lockstep","commit_stats":null,"previous_names":["b-erdem/lockstep"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/b-erdem/lockstep","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/b-erdem%2Flockstep","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/b-erdem%2Flockstep/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/b-erdem%2Flockstep/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/b-erdem%2Flockstep/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/b-erdem","download_url":"https://codeload.github.com/b-erdem/lockstep/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/b-erdem%2Flockstep/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33716338,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["beam","concurrency","concurrency-testing","coyote","elixir","erlang","linearizability","pct","race-condition","testing"],"created_at":"2026-05-31T01:30:48.782Z","updated_at":"2026-05-31T01:30:51.181Z","avatar_url":"https://github.com/b-erdem.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Lockstep\n\n[![CI](https://github.com/b-erdem/lockstep/actions/workflows/ci.yml/badge.svg)](https://github.com/b-erdem/lockstep/actions/workflows/ci.yml)\n[![Hex.pm](https://img.shields.io/hexpm/v/lockstep.svg)](https://hex.pm/packages/lockstep)\n[![Hexdocs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/lockstep)\n[![License](https://img.shields.io/hexpm/l/lockstep.svg)](https://github.com/b-erdem/lockstep/blob/main/LICENSE)\n\nCoyote-style controlled concurrency testing for the BEAM.\n\nYou write what looks like a regular ExUnit test. Lockstep runs it many\ntimes, each time picking a different message-passing schedule via a\nconfigurable strategy. When it finds a bug, it prints the schedule that\ncaused it and saves it to disk so you can reproduce.\n\n## Contents\n\n- [Installation](#installation)\n- [Using Lockstep with AI tools](#using-lockstep-with-ai-tools)\n- [Need this on your system?](#need-this-on-your-system)\n- [Why bother](#why-bother)\n- [What's shipping](#whats-shipping)\n- [What's not shipping yet](#whats-not-shipping-yet)\n- [Quick start](#quick-start)\n- [Showcase: bug classes Lockstep finds](#showcase-bug-classes-lockstep-finds)\n- [Real libraries verified under Lockstep](#real-libraries-verified-under-lockstep)\n- [Configuration](#configuration)\n- [Testing methodology](#testing-methodology)\n- [API](#api)\n- [Reproducing a bug](#reproducing-a-bug)\n- [Shrinking a trace](#shrinking-a-trace)\n- [Long runs and progress](#long-runs-and-progress)\n- [Bug-finding regression suite](#bug-finding-regression-suite)\n- [Design](#design)\n- [License](#license)\n\n## Installation\n\nAdd to your `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:lockstep, \"~\u003e 0.1.0\", only: :test}\n  ]\nend\n```\n\nStatus: **0.1.0 / initial release.** API is stable for the surface\ndocumented here, but expect breakage as we tighten things and respond\nto real-world usage. SemVer applies starting at 1.0.\n\n## Using Lockstep with AI tools\n\nLockstep ships with first-class AI integration:\n\n- **[`AGENTS.md`](AGENTS.md)** — guide for AI coding agents\n  (Claude Code, Cursor, Copilot) to invoke Lockstep via the CLI.\n  Documents the input/output contract for `mix test` /\n  `mix lockstep.replay` / `mix lockstep.shrink`.\n- **[`skill/lockstep/SKILL.md`](skill/lockstep/SKILL.md)** —\n  Anthropic Skill format. Loadable by the Anthropic Agent SDK so\n  agents understand when and how to use Lockstep.\n- **[`mcp_server/`](mcp_server/README.md)** — Model Context Protocol\n  server. Wraps `mix` commands as MCP tools (`lockstep_test`,\n  `lockstep_replay`, `lockstep_shrink`, `lockstep_dump_trace`).\n  Plug into Claude Code via `claude_desktop_config.json`.\n\nThe killer agentic workflow:\n\n1. Agent reads code, hypothesizes a race\n2. Agent generates a Lockstep test file\n3. Agent calls `lockstep_test` (MCP) — Lockstep runs N iterations\n4. If a bug is found, agent reads the trace via `lockstep_replay`\n5. Agent generates a fix\n6. Agent re-runs `lockstep_test` with the same seed to verify\n\n## Need this on your system?\n\nIf you maintain a high-stakes BEAM codebase — a payment processor,\na distributed coordinator, a high-throughput pipeline, a custom\nGenServer that *must* be linearizable — and you want a thorough\nconcurrency audit rather than a one-off bug report, I take on this\nwork directly.\n\nThat includes: applying Lockstep to your code, writing custom\ntest scaffolds and models, running multi-node race-hunting\ncampaigns, hardening hot paths, and delivering a written report\nwith reproducible counterexamples and fixes.\n\n→ **[baris@erdem.dev](mailto:baris@erdem.dev)**\n\n## Why bother\n\nConcrete numbers from\n[`test/value_prop/lost_update_comparison_test.exs`](test/value_prop/lost_update_comparison_test.exs)\nrunning the same lost-update race two ways: ordinary `GenServer.call`\nin a tight loop (200 runs) vs `Lockstep.GenServer.call` under PCT\n(5 seeds × up to 200 iterations).\n\n|                                         | Vanilla `GenServer.call` × 200 | Lockstep PCT × 5 seeds |\n|-----------------------------------------|--------------------------------|-------------------------|\n| Bug detected                            | 198/200 runs (99%)             | 5/5 seeds (100%)        |\n| Time to first detection                 | ~3.6 ms wall-clock             | iterations 1, 2, 2, 4, 15 |\n| Failing schedule recorded               | No                             | Yes (`.lockstep` file)  |\n| Failing schedule prints in human form   | No                             | Yes (alias-mapped trace)|\n| Same input → same failing schedule      | No                             | Yes (same seed → same iteration → identical trace) |\n| Replay on demand                        | No                             | Yes (`Lockstep.Replay.run/2`) |\n\nVanilla testing *finds* this bug just fine — running the body in a\ntight loop catches it 99% of the time. What it can't do is hand you\na specific failing schedule that you can stare at, save, debug, and\nreplay. Each failing run is an opaque \"the counter ended at 1 instead\nof 2\" with no record of which interleaving caused it.\n\nLockstep's value isn't *finding* races — multi-core BEAM finds them\ntoo, eventually. It's giving you a reproducible handle to a *specific*\nfailing schedule:\n\n- **Same seed → same iteration → byte-identical trace.** Three runs of\n  the lost-update test with `seed: 0xC0DE_C0FFEE` all find the bug at\n  iteration 2 with structurally identical traces.\n- **Trace prints as a human-readable schedule** with aliased pids\n  (`P0`, `P1`, ...), step numbers, and a `\u003c-- FAILED HERE` marker.\n- **Replay re-fires the bug** through `Lockstep.Replay.run/2` so you\n  can attach a debugger / add `IO.inspect` / step through.\n\nVanilla `assert_raise` in a loop tells you the test is flaky.\nLockstep tells you *which interleaving* makes it flaky.\n\n### Which strategy?\n\nLockstep ships five strategies (`:random`, `:pct`, `:fair_pct`,\n`:pos`, `:idpct`) plus `:replay` for trace-driven reruns. They\nbehave noticeably differently as race depth grows. From\n[`test/value_prop/strategy_comparison_test.exs`](test/value_prop/strategy_comparison_test.exs)\non a 3-subscribe + 1-publish ordering race, 10 seeds × up to 100\niterations each:\n\n| strategy   | bug found | min iter | median | mean | max |\n|------------|-----------|----------|--------|------|-----|\n| `:random`  | 10/10     | 1        | 3.0    | 3.5  | 7   |\n| `:pct`     | 10/10     | 1        | 1.0    | 1.4  | 2   |\n| `:fair_pct`| 10/10     | 1        | 1.0    | 1.4  | 2   |\n| `:pos`     | 10/10     | 1        | 1.5    | 1.8  | 3   |\n\nLower iteration counts mean the strategy steered toward the bug\nfaster. PCT and Fair-PCT find this race in 1-2 iterations every time;\nrandom takes ~3.5 on average. POS sits between them. On *shallower*\nraces (the depth-2 lost-update demo) all four strategies tie at\niteration 1 — the differentiation only shows up once the race has\nreal depth.\n\nDefault is `:pct` because it gives the most consistent low-iteration\nfinds across both shallow and deep races. Switch to `:fair_pct` if\nyour code has spin loops where pure PCT can starve a low-priority\nprocess; switch to `:pos` if PCT plateaus on a particular bug shape.\n\n\n## What's shipping\n\n### Engine\n- `Lockstep.Test` — ExUnit case template with a `ctest/3` macro that runs\n  the body N times.\n- A user-level scheduler (`Lockstep.Controller`) that intercepts every\n  Lockstep sync point and decides who runs next.\n- Five strategies: `:random`, `:pct`, `:fair_pct`, `:pos`, `:idpct`.\n  - PCT is Burckhardt et al., ASPLOS 2010 — bug-depth probabilistic guarantees.\n  - Fair-PCT is Coyote's hybrid: PCT then random, for liveness.\n  - POS is Yuan et al., OOPSLA 2025 (Fray) — re-randomizes priorities each step.\n  - IDPCT is iterative-deepening PCT — cycles depth in a configured range per iteration.\n- Trace-driven replay: `:replay` strategy + `Lockstep.Replay.run/2`.\n\n### Test surface\n- **Selective receive** via `Lockstep.recv_first/1` — predicate-based\n  pattern matching over the controller-side mailbox.\n- **`Lockstep.GenServer`** — `start_link/2` / `call/2` / `cast/2` /\n  `stop/2` wrapper for ordinary GenServer-style modules. Returns\n  `{:ok, pid}` so OTP-shaped patterns (`{:ok, srv} = ...`) work\n  identically after rewriting.\n- **`Lockstep.GenStatem`** — minimal `:gen_statem`-shaped wrapper\n  (`handle_event_function` mode + `:reply` actions).\n- **`Lockstep.Task`** — `async/1` / `await/1` / `await_many/1`.\n- **Virtual time** — `Lockstep.now/0`, `Lockstep.send_after/3`,\n  `Lockstep.cancel_timer/1`. Time advances only when no managed process\n  is ready and a timer is the only way forward.\n- **Process monitors** — `Lockstep.monitor/1`, `Lockstep.demonitor/2`,\n  with `:DOWN` messages delivered through the controller's mailbox so\n  monitor-based crash detection (NimblePool, Phoenix.PubSub, supervisor\n  patterns) works under controlled scheduling. Monitored crashes don't\n  trigger the child-crash bug verdict — the monitor is treated as\n  \"I expected this death.\"\n- **Links + `:trap_exit`** — `Lockstep.spawn_link/1`, `Lockstep.link/1`,\n  `Lockstep.unlink/1`, `Lockstep.flag/2`. The controller maintains the\n  link graph and propagates abnormal exits with cycle detection:\n  non-trapping linkers cascade-die, trapping linkers receive\n  `{:EXIT, dying_pid, reason}`, normal exits don't propagate. Lets\n  Lockstep run real OTP code that depends on link semantics\n  (NimblePool's worker monitoring, Task supervision, GenServer trees).\n- **Process.alive?** — `Lockstep.alive?/1` consults the controller's\n  view; the call is itself a sync point so the classic\n  `if Process.alive?(pid), do: GenServer.call(pid, ...)` TOCTOU race\n  surfaces (see `test/examples/toctou_alive_test.exs` — Lockstep finds\n  it on iteration 3 under PCT).\n- **Registry** — `Lockstep.Registry` with `:unique` / `:duplicate`\n  keys, monitor-driven automatic cleanup on registering-process death,\n  `dispatch/3` broadcast, and the full `:via` callback contract so\n  `Lockstep.GenServer.start_link(M, A, name: {:via, Lockstep.Registry, {reg, :foo}})`\n  works. Most-common-subset implementation; partitioning, listeners,\n  and `match/4` patterns aren't modeled.\n- **Supervisor** — `Lockstep.Supervisor`, `:one_for_one` strategy,\n  built on `spawn_link` + `trap_exit`. Restart options `:permanent`,\n  `:transient`, `:temporary`. Enforces `max_restarts` / `max_seconds`\n  intensity using Lockstep's virtual clock so restart-loop limits are\n  reproducible across replays.\n- **Compile-time linter** (`Lockstep.Linter`) — warns on bare\n  `send`/`receive`/`spawn`, plain `GenServer.call`/`Task.async`/\n  `Process.send_after`, and ExUnit `assert_receive`/`refute_receive`\n  inside `ctest` bodies.\n- **Compile-time rewriter** (`Lockstep.Rewriter`) — automatically\n  maps vanilla `GenServer.call`, `Task.async`, `send`, `spawn`,\n  `Process.send_after`, and `receive` blocks to their `Lockstep.*`\n  equivalents inside a test body. Lets you write vanilla-OTP-shaped\n  tests and still get controlled scheduling. Enable per-module via\n  `use Lockstep.Test, rewrite: true`, or per-test via\n  `Lockstep.Test.vanilla_run/2`.\n- **Mix compiler** (`Mix.Tasks.Compile.LockstepRewrite`) — applies\n  the same rewriter at the project level: every `lib/**/*.ex` is\n  rewritten to a build directory before the standard Elixir compiler\n  runs. Lets you point Lockstep at unmodified production modules in\n  your own application. Opt in via env-specific\n  `compilers:` / `elixirc_paths:` settings (see \"Project-level\n  rewriting\" below).\n- **Erlang AST rewriter** (`Lockstep.ErlangRewriter`) — same\n  vanilla-OTP-routing trick, but on `.erl` source. Handles\n  `gen_server:call`, `erlang:spawn`, `Pid ! Msg`, bare `receive`,\n  `process_flag(:trap_exit, ...)`, the bare BIF auto-imports\n  (`spawn(F)`), and 4-arg `gen_server:start_link({local, Name}, ...)`\n  atom registration. Lets us pull in foundational Erlang libraries\n  whose `gen_server` machinery is the actual concurrency surface\n  (`:pg`, supervisors, `gen_statem`, `:ssl`'s session manager, etc.).\n  Demonstrated on OTP's `pg.erl` (~720 LOC of pure Erlang, the\n  foundation of Phoenix.PubSub) — compiles through the rewriter and\n  runs under Lockstep's controlled scheduling.\n- **`Lockstep.Agent`** — drop-in for OTP `Agent`. Without it,\n  `Agent.get/3-4` bypasses Lockstep's controller entirely (it's a\n  bare `:gen_server.call`); with it, every agent operation is a\n  sync point. Necessary for finding races in `Agent`-backed code.\n- **`Lockstep.Task` / `Lockstep.Task.Supervisor`** — extended to\n  cover `Task.async_stream/3,4` (honouring `:max_concurrency`),\n  `Task.async/3` (MFA form), and `Task.Supervisor.{start_link, async,\n  start_child, async_stream}`.\n- **NIF-backed shared state** (`Lockstep.ETS`, `Lockstep.Atomics`,\n  `Lockstep.PersistentTerm`) — ETS / `:atomics` / `:persistent_term`\n  ops are individually atomic but compose racefully (the classic\n  `lookup; insert(v+1)` lost-update). Each wrapper inserts a sync\n  point before delegating to the underlying NIF, so the strategy\n  can interleave between calls. The rewriter routes `:ets.*`,\n  `:atomics.*`, `:persistent_term.*` calls through these wrappers.\n  Outside a Lockstep test (no controller in scope) the wrappers\n  fall through to the underlying NIF — same code runs identically\n  in production.\n\n### Tooling\n- Trace capture as `:erlang.term_to_binary` to `.lockstep` files.\n- `mix lockstep.replay --trace path` — print the saved schedule + repro recipe.\n- `mix lockstep.replay --trace path --run \"Mod.fn\" --file path.exs` —\n  re-run a specific test body under the recorded schedule.\n- `mix lockstep.dump_trace --trace path` — raw dump for tooling.\n- Deterministic seeds: same top-level seed reproduces the same iterations.\n\n## What's not shipping yet\n\n- **No DPOR / exhaustive search.** Lockstep is a randomized tester, not\n  a verifier. Concuerror integration is on the roadmap.\n- **No distributed Erlang.** Single-node only.\n- **No `async: true` ExUnit modules.** `use Lockstep.Test` forces\n  `async: false`.\n- **No live iteration progress.** A long-running `iterations: 10_000`\n  run is silent until it either finishes or finds a bug.\n- **`Lockstep.GenStatem`** is `handle_event_function`-mode only;\n  `:state_functions` mode and built-in timeout actions aren't wrapped.\n\n## Quick start\n\n```elixir\n# mix.exs\n{:lockstep, \"~\u003e 0.1.0\", only: :test}\n```\n\nWrite a controlled test:\n\n```elixir\ndefmodule MyApp.RaceTest do\n  use Lockstep.Test, iterations: 200, strategy: :pct\n\n  ctest \"lost update on shared counter\" do\n    parent = self()\n    state = Lockstep.spawn(fn -\u003e stateful_loop(0) end)\n\n    for _ \u003c- 1..2 do\n      Lockstep.spawn(fn -\u003e\n        Lockstep.send(state, {:get, self()})\n        v = Lockstep.recv()\n        Lockstep.send(state, {:put, v + 1})\n        Lockstep.send(parent, :done)\n      end)\n    end\n\n    for _ \u003c- 1..2, do: Lockstep.recv()\n\n    Lockstep.send(state, {:get, self()})\n    assert Lockstep.recv() == 2\n  end\n\n  defp stateful_loop(n) do\n    case Lockstep.recv() do\n      {:get, from} -\u003e Lockstep.send(from, n); stateful_loop(n)\n      {:put, x}    -\u003e stateful_loop(x)\n    end\n  end\nend\n```\n\nRun it:\n\n```bash\nmix test test/my_app/race_test.exs\n```\n\nExpected output on a buggy schedule:\n\n```\nLockstep found a concurrency bug on iteration 2.\n\n  seed:        828370911214\n  iter seed:   1417509209\n  strategy:    :pct\n  trace path:  traces/counter_race-iter2-seed828370911214.lockstep\n\nReason:\n  assertion failed: lost update detected; counter is 1\n\nProcesses:\n  P0(root) = #PID\u003c0.186.0\u003e\n  P1 = #PID\u003c0.187.0\u003e\n  ...\n\nSchedule:\n  step    6  send    P3 -\u003e P1  {:get, ...}\n  step    8  send    P2 -\u003e P1  {:get, ...}\n  step   11  recv    P3  0\n  step   18  recv    P2  0    # both workers read the same value!\n  ...\n\nReplay with:\n    mix lockstep.replay --trace traces/counter_race-iter2-seed828370911214.lockstep\n```\n\n## Showcase: bug classes Lockstep finds\n\nThe `test/examples/` directory contains worked demos. Each is a\nside-by-side of a buggy implementation that Lockstep flags within tens\nof iterations and a fixed implementation that it explores at length\nwithout finding a problem.\n\n### Atomicity violation: lost-uniqueness in a job queue\n\n[`test/examples/unique_jobs_test.exs`](test/examples/unique_jobs_test.exs).\nA textbook bug from the Elixir job-queue ecosystem: naive `submit_unique`\ndoes a `lookup` then an `insert`, which under concurrency lets two\ncallers both observe the key as missing and both insert. DB-backed\nqueues have hit this exact race historically; the fix is always\natomic upsert / `INSERT ... ON CONFLICT`.\n\n```elixir\n# Buggy: split read+write — Lockstep finds duplicate submissions on iter ~15.\ncase Lockstep.GenServer.call(db, {:lookup, key}) do\n  nil -\u003e Lockstep.GenServer.call(db, {:insert, key, val}); :submitted\n  _   -\u003e :already_exists\nend\n\n# Fixed: single atomic call serialized by the GenServer — 100/100 iters pass.\nLockstep.GenServer.call(db, {:put_new, key, val})\n```\n\nThe trace pinpoints the failure: both workers call `:lookup` (steps 6\nand 8), both receive `nil` (steps 11 and 15), both call `:insert`.\n\n### Atomicity violation: read-modify-write on shared state\n\n[`test/examples/genserver_race_test.exs`](test/examples/genserver_race_test.exs).\nTwo workers each do `Counter.get` then `Counter.put(val + 1)`. With\nN=2 workers expecting the counter to reach 2, some interleavings let\nboth reads happen first, both writes set the counter to 1, and one\nupdate is lost. PCT finds it on iteration 4.\n\nThe control test in the same file uses `cast({:add, 1})` instead — the\nGenServer serializes the read-modify-write — and Lockstep proves it\nrace-free across all explored schedules.\n\n### Deadlock: circular GenServer calls\n\n[`test/examples/circular_call_deadlock_test.exs`](test/examples/circular_call_deadlock_test.exs).\nTwo `Echo` GenServers wired up as peers. Each one's `handle_call(:echo,\n...)` synchronously delegates to its peer. Two concurrent callers — one\nto A, one to B — and both servers end up mid-`handle_call` waiting for\nthe other to reply. Classic A-waits-for-B-waits-for-A.\n\nLockstep's `check_progress` step notices that every alive process is\nblocked on `recv_match` with no pending timer that could unblock anyone,\nand aborts the iteration with `:lockstep_deadlock`. The trace shows the\nexact sequence of `gen_call`s that closed the cycle.\n\nThis bug class is hard to catch with property-based testing because it\nneeds a specific interleaving (both callers in flight before either\npeer-call returns) — Lockstep's PCT strategy finds it on iteration 1.\n\n### Auto-rewrite: vanilla OTP code, controlled scheduling\n\n[`test/examples/auto_rewrite_demo_test.exs`](test/examples/auto_rewrite_demo_test.exs).\nSame lost-update race, written entirely with vanilla `GenServer.call`,\n`Task.async`, `Task.await_many` — no `Lockstep.*` calls in the test\nbody. The `Lockstep.Test.vanilla_run/2` macro walks the body's AST\nand rewrites every recognized OTP call to its Lockstep equivalent\nbefore handing it to the runner.\n\n```elixir\ntest \"auto-rewrite finds the race in vanilla OTP code\" do\n  assert_raise Lockstep.BugFound, fn -\u003e\n    Lockstep.Test.vanilla_run iterations: 100, strategy: :pct, seed: 1 do\n      {:ok, c} = GenServer.start_link(Counter, 0)\n\n      tasks =\n        for _ \u003c- 1..2 do\n          Task.async(fn -\u003e\n            v = GenServer.call(c, :get)\n            :ok = GenServer.call(c, {:set, v + 1})\n          end)\n        end\n\n      Task.await_many(tasks)\n\n      final = GenServer.call(c, :get)\n      if final != 2, do: raise \"lost update; counter is #{final}\"\n    end\n  end\nend\n```\n\nLockstep finds the race on iteration 2 with the same trace it would\nhave produced for the manually-written equivalent. Useful when you\nwant test bodies to read like ordinary Elixir, when you're porting\nan existing flaky test to Lockstep, or when you want to demonstrate a\nrace in a vanilla snippet without forcing readers to learn the\n`Lockstep.send`/`Lockstep.recv` API up-front.\n\nThe rewriter handles `GenServer.{call, cast, start_link, stop}`,\n`Task.{async, await, await_many}`, `send`, `Kernel.send`,\n`:erlang.send`, `spawn`, `spawn_link`, `Process.send_after`,\n`Process.cancel_timer`, and `receive do clauses end` (translates to\n`Lockstep.recv_first` + `case`). Helper functions defined outside the\ntest body are *not* rewritten by `vanilla_run`/`ctest rewrite: true`\n— inline race code into the body, or use the project-level Mix\ncompiler below to rewrite an entire `lib/`.\n\n### Project-level rewriting (Mix compiler)\n\nTo test unmodified modules in your `lib/` (or in a dep) without\nchanging source, plug Lockstep into your test compile pipeline:\n\n```elixir\n# mix.exs\ndef project, do: [\n  ...\n  compilers: compilers(Mix.env()),\n  elixirc_paths: elixirc_paths(Mix.env()),\n  lockstep_rewrite: lockstep_rewrite(Mix.env()),\n]\n\ndefp compilers(:test), do: [:lockstep_rewrite] ++ Mix.compilers()\ndefp compilers(_),     do: Mix.compilers()\n\ndefp elixirc_paths(:test), do: [\"_build/test/lockstep_rewritten/lib\", \"test/support\"]\ndefp elixirc_paths(_),     do: [\"lib\"]\n\ndefp lockstep_rewrite(:test), do: %{paths: [\"lib/**/*.ex\"]}\ndefp lockstep_rewrite(_),     do: nil\n```\n\nIn `:test`, every matching `.ex` is rewritten under\n`_build/test/lockstep_rewritten/lib/` and the standard Elixir\ncompiler picks it up from there. Production / dev compilation is\nuntouched — no `Lockstep.*` runtime cost.\n\nThe same machinery works for dep code: include `\"deps/\u003cdep\u003e/lib/**/*.ex\"`\nin the `paths:` list. The rewriter handles `GenServer.{call, cast,\nstart_link, stop, reply}`, `Task.{async, await, await_many}`,\n`send`, `spawn`, `spawn_link`, `Process.{send_after, cancel_timer,\nmonitor, demonitor}`, and `receive` blocks. `Process.flag(:trap_exit, …)`\nand `:erlang.spawn/3` (MFA form) are left alone.\n\nEnd-to-end NimblePool dep-rewrite demo:\n[`test/phase2_dep_rewrite_test.exs`](test/phase2_dep_rewrite_test.exs).\n\n### CubDB historical race (commit 60fb38f)\n\n[`test/examples/cubdb_compaction_race_test.exs`](test/examples/cubdb_compaction_race_test.exs).\nModels the race upstream CubDB fixed in\n[commit 60fb38f](https://github.com/lucaong/cubdb/commit/60fb38f)\n(\"Fix race condition during compaction\"). Quoting the maintainer:\n\n\u003e The compaction process actually happens in two phases: actual\n\u003e compaction, and catch-up. The previous implementation was starting\n\u003e the catch-up phase asynchronously, introducing a race condition:\n\u003e right after actual compaction completed, and before catch-up\n\u003e started, there was the possibility to erroneously start another\n\u003e concurrent compaction.\n\nThe shape: a multi-phase operation guarded by a `busy` flag. Phase 1\ncompletion clears `busy` AND queues phase 2 as a separate message.\nBetween those two events a new operation passes the `busy?` check and\nstarts a *concurrent* compaction. Phase 2 then sees state overwritten\nby the second compaction.\n\nDetection in our model: each compaction has a unique ID, written to\n`state.current_id` on `start_compaction`. Phase 2 verifies the ID it\nholds matches `state.current_id`; if a second compaction has run in\nbetween and overwritten the field, the assertion fires.\n\nSweep result over 4 strategies × 5 seeds × 200 iterations each:\n\n| strategy   | finds the race | typical iter |\n|------------|-----------------|--------------|\n| `:pos`     | 5/5 seeds       | 1, 1, 3, 5, 6 |\n| `:random`  | 5/5 seeds       | 1, 3, 3, 4, 9 |\n| `:fair_pct`| 1/5 seeds       | 53            |\n| `:pct`     | 0/5 seeds       | n/a           |\n\nPCT's bug-depth heuristic happens to bias *away* from this particular\nrace shape — the change-point insertion at d-1 random steps doesn't\nalign with the phase-1/phase-2 mailbox window. POS's per-step\npriority resampling finds it consistently. **A real consequence of\nhaving multiple strategies bundled.**\n\nThe fix in the model (and upstream): keep `busy=true` until BOTH\nphases complete. The intermediate mailbox state — phase 2 queued but\nnot run — no longer admits a new `:start_compaction`. Verified\nrace-free across the same 200 iterations × `:pct`.\n\n### Honeydew historical race (commit 41831c8)\n\n[`test/examples/honeydew_status_race_test.exs`](test/examples/honeydew_status_race_test.exs).\nModels the race upstream Honeydew fixed in\n[commit 41831c8](https://github.com/koudelka/honeydew/commit/41831c8):\n\n\u003e Fixing race condition with Honeydew.status/1 where monitors could\n\u003e die before being queried for their current job.\n\nThe original buggy pattern:\n\n```elixir\nbusy_workers =\n  queue\n  |\u003e get_all_members(:monitors)\n  |\u003e Enum.map(\u0026GenServer.call(\u00261, :current_job))\n```\n\nRace: between `get_all_members/2` returning a list of monitor pids\nand `GenServer.call/2` reaching each one, a monitor can exit. The\ncall raises `:exit`, crashing the entire `status/1` invocation.\n\nWe can't compile and run upstream Honeydew under Lockstep — it's a\nfull OTP application with `:pg`, supervisors, and Mnesia plumbing\nbeyond our rewriter's reach — but the race shape is universal.\nThe minimal reproduction (3 monitors, one killed concurrently with\nthe iteration) **fails on iteration 2** under PCT. The fixed pattern\n(`try/catch :exit, _` around each call) passes 200 iterations.\n\nThe point isn't that we found this bug — the maintainer fixed it in\n2017. The point is that **the same pattern in any monitor-based\nlibrary would surface under Lockstep**, and the failing schedule is\na one-line change in user code (the missing `try/catch`) away from a\nclean test.\n\n### Real bug found: Phoenix.Tracker's pre-fc5686f ETS race\n\n[`test/phoenix_tracker_prefix_race_test.exs`](test/phoenix_tracker_prefix_race_test.exs).\nLockstep finds the race fixed upstream in\n[fc5686f](https://github.com/phoenixframework/phoenix_pubsub/commit/fc5686f1879bdad0856970cd292844202d91433a)\n(\"Update ETS atomically on tracker update\").\n\nPre-fix `Tracker.update` did `State.leave` then `put_presence` —\ntwo ETS writes back-to-back. A concurrent `Tracker.list` could\nobserve the table in between, with the entry temporarily missing.\nThis is exactly the kind of race that's invisible at the\nmessage-passing level — it lives at the ETS NIF level.\n\nWith `Lockstep.ETS` providing sync points at every ETS call,\nthe strategy can interleave `Tracker.list` between the leave and\nthe put. **POS finds it on iteration 1.** The whole-of-Tracker\nplus Phoenix.PubSub plus rewritten OTP `:pg` (~3,700 LOC) runs\nunmodified, with sync points injected at NIF boundaries by our\nrewriter.\n\n### Real bug found in NimblePool's history\n\nLockstep finds a real race in NimblePool's\n`checkout!` timeout handling, fixed upstream in commit\n[e18f45f](https://github.com/dashbitco/nimble_pool/commit/e18f45f79c7eac1766e11f1c7cf9d361903f7f7f)\n(\"Cancel requests on client timeout\").\n\n[`test/nimble_pool_timeout_race_test.exs`](test/nimble_pool_timeout_race_test.exs)\ntakes the pre-fix NimblePool source (just one commit before the\nfix), compiles it through `Lockstep.MixCompiler`, and runs a\nrealistic scenario:\n\n1. Capacity-1 pool\n2. Holder client checks out and refuses to release\n3. Waiter client calls `checkout!(pool, ..., 50ms)` and uses\n   `try/catch :exit, _` to handle the deadline (real production\n   pattern)\n4. Waiter's timeout fires\n5. Holder is then told to release\n\nUnder wall-clock testing this race rarely surfaces — it depends on\nthe scheduler interleaving the timeout and the holder's release in\na particular way. **Lockstep's PCT strategy finds it on iteration 1**,\nproducing the exit:\n\n```\nchild crashed: exit(:unexpected_remove)\n```\n\nThe pool process itself dies. In production, anyone calling\n`checkout!` with a `try/catch :exit, _` on this version would\nintermittently bring down their entire pool.\n\nThe upstream fix has two parts: send a cancel message from the\nclient before exiting, and turn the `:unexpected_remove` crash into\na no-op for late-arriving removals. With the fix applied (current\nNimblePool releases) Lockstep's same scenario runs clean.\n\n### Real-world race patterns\n\nBeyond the libraries the rewriter handles directly, the\nreal-world-patterns suite reproduces *bug shapes seen in production\nBEAM systems* in standalone form, exercising Lockstep's Registry /\nSupervisor / link / monitor support.\n\n[`test/examples/real_world_patterns/cache_thundering_herd_test.exs`](test/examples/real_world_patterns/cache_thundering_herd_test.exs).\nThe classic \"lookup → miss → populate → put\" anti-pattern. N\nconcurrent clients each see a miss, each populate, end up\noverwriting each other. Lockstep finds it on **iteration 1** under\nPCT. The locked-populate fix runs 100 iterations clean.\n\n[`test/examples/real_world_patterns/pubsub_fanout_ack_test.exs`](test/examples/real_world_patterns/pubsub_fanout_ack_test.exs).\nPubSub broadcast that waits for acks from every subscriber. If one\nsubscriber dies between `Registry.lookup` and acking, the broadcaster\nblocks forever. Found on **iteration 1** under PCT. The fix is\nmonitor-based ack accounting — treat `:DOWN` as a counted ack — and\nruns clean across 50 iterations.\n\nThese patterns are what `Lockstep.Registry`, `Lockstep.Supervisor`,\n`Lockstep.spawn_link`, `Lockstep.flag(:trap_exit, true)`, and\n`Lockstep.alive?` were *built for*. Each lives in the bug zoo\nregression suite so future engine changes can't quietly break their\nfindability.\n\n## Real libraries verified under Lockstep\n\nBeyond the bug-class showcase above, Lockstep has been pointed at\nproduction BEAM libraries — both pre-fix versions to confirm it\ncatches known races, and current versions to gather verification\nevidence (\"no race in N controlled schedules\").\n\n| Library | Mode | Result | Test |\n|---|---|---|---|\n| **Hammer** | rewrite + run | **Real bug** — `Atomic.FixWindow.inc/4` lost-update vs `hit/5`, found under PCT | [`test/hammer_test.exs`](test/hammer_test.exs) |\n| **Phoenix.Tracker** (pre-fc5686f) | rewrite + run | **Real bug** — ETS race fixed in fc5686f; POS finds it iter 1 | [`test/phoenix_tracker_prefix_race_test.exs`](test/phoenix_tracker_prefix_race_test.exs) |\n| **NimblePool** (pre-e18f45f) | rewrite + run | **Real bug** — checkout timeout race; PCT finds it iter 1 | [`test/nimble_pool_timeout_race_test.exs`](test/nimble_pool_timeout_race_test.exs) |\n| Phoenix.PubSub (current) | rewrite + run | Verified clean — 70 PCT iterations across 3 race-hunt scenarios | [`test/phoenix_pubsub_race_hunt_test.exs`](test/phoenix_pubsub_race_hunt_test.exs) |\n| GenStage (current) | rewrite + run | Verified clean — single + fan-out pipeline scenarios | [`test/gen_stage_test.exs`](test/gen_stage_test.exs) |\n| OTP `:pg` (Erlang) | Erlang rewriter + run | Verified clean — joins/broadcasts under interleaved scheduling | [`test/erlang_pg_test.exs`](test/erlang_pg_test.exs) |\n| Mutex (lud/mutex) | rewrite + run | Verified clean — 10,000 schedules across 5 scenarios | [`test/real_libraries_test.exs`](test/real_libraries_test.exs) |\n| ConCache.Lock | rewrite + run | Verified clean — 4,000 schedules across 5 scenarios | [`test/concache_lock_hunt_test.exs`](test/concache_lock_hunt_test.exs) |\n| NimblePool port | reimplemented | Verified clean — 1,200 schedules across 6 scenarios | [`test/examples/nimble_pool_demo_test.exs`](test/examples/nimble_pool_demo_test.exs) |\n| Cachex | test-only Hex dep | Linearizable — 200 iter mixed ops + 500 iter R-M-W | [`test/cachex_test.exs`](test/cachex_test.exs) |\n\nThe two work modes:\n\n- **`rewrite + run`** — `Lockstep.MixCompiler` rewrites the\n  upstream source, recompiles it, and the test drives the actual\n  upstream API under controlled scheduling. Sync points sit at\n  every `GenServer.call`, `:ets.*`, `Process.monitor`, `send`,\n  `receive`, etc.\n- **`Erlang rewriter + run`** — same pattern but for `.erl`\n  source: `Lockstep.ErlangRewriter` walks the Erlang abstract\n  format and routes `gen_server:call`, `erlang:spawn`,\n  `Pid ! Msg`, bare `receive`, etc. through the controller. Used\n  for OTP modules like `:pg`, `:gen_statem`, supervisor\n  internals.\n- **`test-only Hex dep`** — the library compiles unmodified; the\n  test wraps its API in `Lockstep.GenServer.call`-shape sync\n  points at the boundary. Useful when the lib is too large or\n  macro-heavy to rewrite cleanly.\n\nFor the methodology behind picking a mode, writing the model, and\nsizing iterations, see [METHODOLOGY.md](METHODOLOGY.md).\n\n## Configuration\n\n`use Lockstep.Test` accepts:\n\n| Option          | Default | Meaning                                       |\n|-----------------|---------|-----------------------------------------------|\n| `:iterations`   | `100`   | How many randomized schedules to try.         |\n| `:strategy`     | `:pct`  | `:random | :pct | :fair_pct | :pos | :idpct`. |\n| `:seed`         | random  | Top-level RNG seed; per-iteration seeds derive from it. |\n| `:max_steps`    | `1000`  | Hard limit on scheduling steps per iteration. |\n| `:iter_timeout` | `30_000` | Hard wall-clock timeout per iteration (ms).  |\n\n## Testing methodology\n\nFor applying Lockstep to a real BEAM library — the model-based\nlinearizability template, the compile-rewrite vs test-only-dep paths,\nstrategy selection, fault injection, and case studies (Cachex,\nConCache, lud/mutex, nimble_pool, Hammer) — see\n[METHODOLOGY.md](METHODOLOGY.md). Includes a real bug we found in\nHammer's `Hammer.Atomic.FixWindow.inc/4` (lost-update race against\n`hit/5`).\n\n## API\n\nThe runtime API for use inside `ctest` bodies:\n\n| Call                         | Description                                                                       |\n|------------------------------|-----------------------------------------------------------------------------------|\n| `Lockstep.spawn/1`           | Spawn a managed child process.                                                    |\n| `Lockstep.spawn_link/1`      | Spawn a managed child and bidirectionally link to it (atomic).                    |\n| `Lockstep.send/2`            | Send to another managed process (recorded by controller).                         |\n| `Lockstep.recv/0`            | Receive the next message in delivery order.                                       |\n| `Lockstep.recv_first/1`      | Selective receive: take first message matching a predicate.                       |\n| `Lockstep.now/0`             | Read the controller's virtual clock.                                              |\n| `Lockstep.send_after/3`      | Schedule a message after `delay_ms` of virtual time. Returns a timer ref.         |\n| `Lockstep.cancel_timer/1`    | Cancel a pending timer; returns ms remaining or `false`.                          |\n| `Lockstep.sleep/1`           | Virtual-time sleep (sentinel timer + selective receive under the hood).           |\n| `Lockstep.monitor/1`         | Monitor a managed process; `:DOWN` is delivered through Lockstep's mailbox.       |\n| `Lockstep.demonitor/2`       | Stop monitoring; `:flush` removes any already-delivered `:DOWN`.                  |\n| `Lockstep.alive?/1`          | Consult the controller's view; falls back to vanilla for unmanaged pids.          |\n| `Lockstep.link/1`            | Bidirectionally link to another managed process.                                  |\n| `Lockstep.unlink/1`          | Remove the link.                                                                  |\n| `Lockstep.flag/2`            | `:trap_exit` is modeled at the controller level; other flags are placeholders.    |\n\nHigher-level wrappers built on these primitives:\n\n- `Lockstep.GenServer.{start_link, call, cast, stop}` — test modules\n  that follow the GenServer callback contract. `start_link/3` accepts\n  `name: {:via, Module, term}`.\n- `Lockstep.Task.{async, await, await_many}` — `Task.async`-style\n  fan-out / fan-in with selective-receive on a unique ref.\n- `Lockstep.Registry.{start_link, register, unregister, lookup, dispatch, count, keys}` —\n  controller-aware key/pid registry with `:via` integration.\n- `Lockstep.Supervisor.{start_link, which_children, count_children, start_child, terminate_child, restart_child}` —\n  `:one_for_one` supervisor with permanent/transient/temporary restart\n  semantics and intensity limits.\n\nEvery primitive is a sync point — every call hands control back to the\ncontroller, which picks the next process to run per the configured\nstrategy. The send / spawn variants don't block the user-side caller\nfor delivery semantics; the controller records / queues the action and\nresumes the caller at its next scheduling turn. `recv/0` and\n`recv_first/1` block until the controller delivers a message.\n\n`Lockstep.alive?/1` being a sync point is the point: TOCTOU bugs\n(`if Process.alive?(pid), do: GenServer.call(pid, ...)`) surface\nnaturally because the strategy may interleave another process between\nthe check and the subsequent call.\n\n`Lockstep.link/1` and friends model the BEAM link graph at the\ncontroller level. When a linked process dies abnormally, the cascade\nis propagated through Lockstep's link graph (with cycle detection):\nnon-trapping linkers die transitively, and the chain terminates either\nat a `flag(:trap_exit, true)` process — which receives a synthetic\n`{:EXIT, dying_pid, reason}` message — or at the test root, which\nfinalizes the iteration as a bug.\n\n### Selective receive\n\n```elixir\nref = make_ref()\nLockstep.send(server, {:request, self(), ref})\n\nreply =\n  Lockstep.recv_first(fn\n    {^ref, payload} -\u003e {:ok, payload}\n    _              -\u003e false   # any non-truthy means \"skip\"\n  end)\n```\n\nOther messages already in the mailbox are not disturbed.\n\n### GenServer wrapper\n\n```elixir\ndefmodule Counter do\n  def init(initial), do: {:ok, initial}\n  def handle_call(:get, _from, n),         do: {:reply, n, n}\n  def handle_call({:put, x}, _from, _n),   do: {:reply, :ok, x}\n  def handle_cast({:add, x}, n),           do: {:noreply, n + x}\nend\n\nctest \"lost update on counter\" do\n  srv = Lockstep.GenServer.start_link(Counter, 0)\n\n  for _ \u003c- 1..2 do\n    Lockstep.spawn(fn -\u003e\n      v = Lockstep.GenServer.call(srv, :get)\n      :ok = Lockstep.GenServer.call(srv, {:put, v + 1})\n    end)\n  end\n\n  # ... barrier ...\n  assert Lockstep.GenServer.call(srv, :get) == 2\nend\n```\n\nThe wrapper itself is a thin loop on top of `Lockstep.recv`/`Lockstep.send`/\n`Lockstep.recv_first`. See `lib/lockstep/gen_server.ex` for the\n~80-line implementation.\n\n### Virtual time\n\n```elixir\nctest \"GenServer with periodic tick\" do\n  pid = Lockstep.GenServer.start_link(MyTimerServer, [])\n  Lockstep.send_after(pid, :start, 0)\n\n  # Drain the test forward until 350ms of virtual time has passed.\n  Lockstep.send_after(self(), :wakeup, 351)\n  _ = Lockstep.recv()\n\n  assert Lockstep.GenServer.call(pid, :tick_count) \u003e= 3\nend\n```\n\nVirtual time only advances when the controller would otherwise\ndeadlock — when every managed process is blocked on receive and the\nonly way to unblock someone is to fire the next pending timer. So\n`Lockstep.send_after(pid, :ding, 86_400_000)` followed by `recv` returns\nin milliseconds of wall-clock time, not a day. Same trick `:meck`/Mox\nlet you do for `:erlang.system_time`, but built into the scheduler.\n\n## Reproducing a bug\n\nThree ways, in order of laziness:\n\n**1. Re-run the suite with the original seed.** The trace records both\nthe top-level seed and the iteration. Re-run with `seed: \u003ctop_seed\u003e` and\n`iterations: \u003e= \u003citer\u003e`. Per-iteration seed derivation is\n`phash2({top_seed, iteration})`, so iteration K of the same top seed\nalways behaves identically.\n\n**2. Replay from the CLI.** Extract the test body into a named function,\nthen:\n\n```bash\nmix lockstep.replay --trace traces/foo.lockstep \\\n                    --run \"MyApp.RaceTest.lost_update_body\" \\\n                    --file test/my_app/race_test.exs\n```\n\nThe mix task loads the test file (so the module becomes available),\nthen runs `Lockstep.Replay.run/2` with the schedule from the trace.\n\n**3. Replay programmatically.** Same idea, written as an ordinary\nExUnit test:\n\n```elixir\ndefmodule MyApp.RaceTest do\n  use Lockstep.Test, ...\n\n  # The body is extracted into a function so it can be called from\n  # both the ctest and a one-off replay test.\n  def lost_update_body do\n    # ... the actual test scenario ...\n  end\n\n  ctest \"lost update\" do\n    lost_update_body()\n  end\nend\n\ndefmodule MyApp.ReplayTest do\n  use ExUnit.Case\n\n  test \"replay the lost-update bug\" do\n    Lockstep.Replay.run(\n      \u0026MyApp.RaceTest.lost_update_body/0,\n      \"traces/MyApp-iter2-seed828370911214.lockstep\"\n    )\n  end\nend\n```\n\nIf the user code is fully deterministic (no raw `:rand`, no real I/O,\nno NIFs that yield asymmetrically), the replay produces a structurally\nidentical trace and re-fires the bug. If something diverges, you get a\n`Lockstep.ReplayDivergence` with a step number and a hint.\n\nYou can also compare two traces structurally:\n\n```elixir\nLockstep.Replay.compare_traces!(original_trace, replayed_trace)\n```\n\nPids and refs are sanitized before comparison since they're recreated\non each run.\n\n## Shrinking a trace\n\nA failing trace can be hundreds of events. Most of those are *necessary\nsetup* but obscure the actual race. `Lockstep.Shrink.shrink/3` searches\nfor a shorter `replay_pid_order` that still triggers the same bug\n*signature* under strict replay, then saves a `.shrunk.lockstep` next\nto the original.\n\n```bash\nmix lockstep.shrink --trace traces/counter_race-iter5-seed42.lockstep \\\n                    --run \"MyApp.CounterRaceTest.lost_update_body\" \\\n                    --file test/my_app/counter_race_test.exs\n```\n\nOutput:\n\n```\nLockstep shrink succeeded:\n  original schedule decisions: 87\n  shrunk schedule decisions:   12\n  attempts:                    156\n  bug signature:               {:exception, RuntimeError}\n  shrunk trace:                traces/counter_race-iter5-seed42.shrunk.lockstep\n```\n\nThe shrunk trace replays exactly like the original — point\n`mix lockstep.replay` at it for the same bug at fewer scheduling\ndecisions. Programmatic version:\n\n```elixir\n{:ok, info} =\n  Lockstep.Shrink.shrink(\n    fn -\u003e MyApp.CounterRaceTest.lost_update_body() end,\n    \"traces/counter_race-iter5-seed42.lockstep\",\n    verbose: true\n  )\n\ninfo.original_length  # 87\ninfo.new_length       # 12\ninfo.new_trace_path   # \"traces/...shrunk.lockstep\"\n```\n\nThe algorithm is two-pass — truncation (shorter prefixes) then\ndecimation (drop individual decisions) — repeated to a fixed point.\nBug-signature comparison is coarse (same exception type, same\ndeadlock category, etc.), so the shrunk trace is a *cleaner repro*\nnot a byte-identical reproduction.\n\n## Long runs and progress\n\n`iterations: 10_000` is silent by default until it either finishes\nor finds a bug. Pass `progress:` to opt in:\n\n```elixir\nLockstep.Runner.run(\n  fn -\u003e MyTest.race_body() end,\n  iterations: 10_000,\n  strategy: :pct,\n  progress: true                 # prints every ~5%\n  # progress: 100                # or: every 100 iterations\n  # progress: fn iter -\u003e ... end # or: a custom hook (telemetry, ...)\n)\n```\n\n## Bug-finding regression suite\n\n`test/bug_zoo_test.exs` is a curated set of known concurrency races,\neach declared with a `(strategy, seed, max_iterations, expected_at_most)`\nconfiguration. The test fails if any race takes more iterations than\nits baseline — catching engine regressions in bug-finding power as\nthe controller and strategies evolve.\n\nWhen making engine changes, run `mix test test/bug_zoo_test.exs` to\nconfirm bug-finding hasn't slipped. Add new entries as you wire up new\nclasses of bug.\n\n## Design\n\nThe shape mirrors Microsoft Coyote, mapped to BEAM:\n\n| Coyote (.NET)                     | Lockstep (BEAM)                |\n|-----------------------------------|--------------------------------|\n| `coyote rewrite` (IL rewriter)    | Runtime `Lockstep.send/recv/spawn` API |\n| `TestingEngine`                   | `Lockstep.Runner.run/2`        |\n| `IActorRuntime`                   | `Lockstep.Controller` GenServer |\n| Strategies (random, PCT, ...)     | `Lockstep.Strategy` behaviour  |\n| `coyote replay \u003ctrace\u003e`           | `mix lockstep.replay`          |\n\n## License\n\nApache-2.0.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fb-erdem%2Flockstep","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fb-erdem%2Flockstep","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fb-erdem%2Flockstep/lists"}