unluck
v0.1.1
Published
Deterministic simulation testing for Node. Finds bugs that only happen under unlucky timing, then replays them exactly.
Maintainers
Readme
unluck
Deterministic simulation testing for Node.
unluck finds bugs that only happen under unlucky timing — a lost reply, a retry
that lands twice, a request that beats its own response — and then replays the
exact failing run, forever. The code under test imports nothing from unluck. It
keeps using fetch, setTimeout, Date.now, Math.random and
crypto.randomUUID; unluck controls what those return.
The problem
Your integration test calls the service once, over a healthy network, and passes. Production runs the same code a million times over a network that drops 0.4% of replies, and the retry path — the one your test never entered — applies the charge twice. When it does show up in CI it shows up as a flaky test, one run in thirty, and it gets muted rather than fixed.
60 seconds
One file. The top half is an ordinary retrying HTTP client and the service it talks to. The bottom half is the test.
// billing.sim.mjs
import { pathToFileURL } from 'node:url';
import { scenario, search, shrink, replay } from 'unluck';
// ---- the application: ordinary Node, imports nothing from unluck -----------
function createBilling() {
const applied = new Map(); // id -> times we actually charged
const acked = new Set(); // every id we answered OK to
return { applied, acked, handle({ id, amount }) {
acked.add(id);
applied.set(id, (applied.get(id) ?? 0) + 1);
return { ok: true, id };
} };
}
async function chargeCustomers({ count = 5, attempts = 3 } = {}) {
for (let i = 0; i < count; i++) {
const id = `charge-${i}-${crypto.randomUUID().slice(0, 8)}`;
for (let n = 1; n <= attempts; n++) {
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 10) + 1));
try {
const res = await fetch('http://billing.internal/charge', {
method: 'POST', body: JSON.stringify({ id, amount: 10 }),
});
if (res.ok) break;
} catch { /* timed out; retry with the same id */ }
}
}
}
// ---- the test -------------------------------------------------------------
const billing = scenario({
name: 'billing/retry-is-not-idempotent',
url: import.meta.url, // lets replay re-import this file
setup: () => ({ svc: createBilling() }),
services: (s) => ({ 'billing.internal': (body) => s.svc.handle(body) }),
run: () => chargeCustomers({ count: 5 }),
invariant: (s) => {
for (const id of s.svc.acked) {
const n = s.svc.applied.get(id) ?? 0;
if (n !== 1) return { ok: false, detail: `${id} was acknowledged but charged ${n} times` };
}
return { ok: true };
},
});
export default billing;
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const faults = { dropRequest: 0.004, dropResponse: 0.004, duplicateRequest: 0.002,
minLatency: 1, maxLatency: 12, requestTimeoutMs: 60 };
const found = await search(billing, { seeds: 500, faults, stopOnFirst: false });
console.log(`search ${found.failures}/${found.searched} seeds failed (${(found.rate * 100).toFixed(1)}%) in ${found.elapsedMs}ms`);
console.log(` seed ${found.counterexample.seed}: ${found.counterexample.detail}`);
const small = await shrink(billing, found.counterexample);
console.log(`shrink ${small.before.faults} faults -> ${small.after.faults} (minimal: ${small.minimal})`);
console.log(` ${small.faults.join(', ')}`);
const r = await replay(billing, small.counterexample);
console.log(`replay fresh process: ${r.isolated} reproduced: ${r.reproduced}`);
console.log(` digest ${r.digest} == recorded ${r.expectedDigest}, ${r.tapeHits} tape hits, ${r.tapeMisses} PRNG draws`);
}node billing.sim.mjs:
search 17/500 seeds failed (3.4%) in 222ms
seed 54: charge-1-5ba8cef6 was acknowledged but charged 2 times
shrink 11 faults -> 1 (minimal: true)
net.drop-response:charge-1#1
replay fresh process: true reproduced: true
digest b3493a0a798e623a == recorded b3493a0a798e623a, 40 tape hits, 0 PRNG drawsRead the three lines in order.
3.4% of seeds. That is the flaky test. Roughly one run in thirty, which is exactly the rate at which a failure gets re-run instead of investigated.
11 faults down to 1. Seed 54 injected eleven separate perturbations. Ten of
them were irrelevant. minimal: true is not a claim — unluck removed each
surviving fault in turn and required the case to pass. One dropped reply, on the
second charge, is the entire bug.
0 PRNG draws. The replay was driven by the recorded choice tape, not by re-rolling the seed, in a process that had never run the scenario before. The digest matched the recorded one, so this is the same run byte for byte — not merely "it broke again".
Install
npm install --save-dev unluckNode 22 or newer. No runtime dependencies.
What it intercepts
Replaced for the duration of a run, with no change to the code under test.
Rows marked † need the loader hook (--import unluck/register, or the CLI,
which installs it for you); everything else works with no setup.
| | |
|---|---|
| setTimeout / clearTimeout | real Timeout objects with ref/unref/refresh, on a virtual clock |
| setInterval / clearInterval | same |
| fetch | routed to your services handlers; drops, duplicates, latency and timeouts injected |
| † node:http / node:https | simulated — requires --import unluck/register or the CLI, since the builtin must be redirected before your modules link. Covers got and axios's default adapter |
| crypto.subtle digest / HMAC | backed by Node's synchronous crypto so completion is scheduled; byte-identical results |
| Date.now() and new Date() | the virtual clock |
| performance.now() | the virtual clock |
| Math.random() | seeded, and recorded on the tape |
| crypto.randomUUID() | seeded, and recorded on the tape |
| † node:timers/promises, named node:crypto imports | seeded and virtual; a named builtin import is invisible to a global patch |
The virtual clock starts at 1767225600000 (2026-01-01T00:00:00Z) rather than
0, because timestamp 0 is an "unset" sentinel in real libraries and hangs them.
Delays are clamped to at least 1ms, as Node does.
Sixteen popular packages were exercised under interception: 13 work unchanged,
2 (nanoid, uuid.v7) work only when the replay is process-isolated, and 1
(piscina) does not work. See docs/limitations.md.
Three ways in
- The library API —
scenario/search/shrink/replay, as above. See docs/api.md. - Inside your test runner —
simulate()/regression()drop intonode:testor vitest and throw a shrunk, readable report when a seed falsifies your invariant. See docs/quickstart.md. - The CLI —
unluck search,replay,shrink,check. Use it when the code under test doesimport { setTimeout } from 'node:timers/promises'; the CLI is the only entry point that installs the loader hook that reaches named builtin imports.
Limitations, briefly
It interleaves tasks at simulated I/O only. A race whose window contains no
simulated call — two tasks racing across an in-process mutex, say — is not
explored at all, and you will get a clean run over any number of seeds. Measured
both sides of that boundary in docs/limitations.md; read it before trusting a
green result.
unluck simulates the network at the request/response level, not the wire. It
does not speak socket or binary protocols, so redis and mysql2 are outside
it. pg is simulated at the client's own API (Client/Pool/query), not
over a socket — so transactions, lost COMMIT acknowledgements and pool
exhaustion are reachable, while COPY, LISTEN/NOTIFY, cursors and real
unique constraints are not.
setImmediate is not scheduled by the simulator and will hang a run. Real I/O
(DNS, real sockets, child processes) never completes inside a run. Worker
threads work, but piscina does not: it hands each worker a MessagePort in a
transferList and then blocks on Atomics.wait, which cannot work when every
simulated thread shares one real thread.
Two of those fail by leaving the run unfinished rather than by throwing, so read docs/limitations.md before you trust a clean run. It lists what fails silently and the specific tell for each.
Documentation
| | | |---|---| | docs/quickstart.md | install to found-and-replayed bug, in your test runner | | docs/api.md | every public export, with signatures and examples | | docs/recipes.md | retrying clients, webhooks, job queues, transactions, worker pools | | docs/limitations.md | what it cannot see, and how to notice | | docs/how-it-works.md | the scheduler, the tape, swarm testing, and the evidence | | PRIOR-ART.md | the fourteen in-house builds and three Rust projects this came from |
Verifying the build
npm test # 42 checksEach check calibrates in both directions: it plants the defect and requires the harness to catch it, then removes the defect and requires a clean run. On this machine the whole gate takes about 20s.
License: MIT.
