libpetri
libpetri replaces the async code that manages locks, pool slots and permits and
waits for earlier results: async-mutex, p-limit, Promise.all and the code around them. You write the same steps,
but each step declares what it takes and what it returns, and the library runs them (the wiring is declarative, the
step bodies stay plain async functions).
In return you get one thing that normal code cannot give: a test that proves properties of the steps for every possible order they can run in. Among them: the steps never hang waiting for each other, a pool never gives out more slots than it has, two steps never hold the same thing at once, and a state you name is never reached.
The downside: you rewrite working code with new constructs, and the price is about four times more code. The upside: the model and the code are one thing, in one place. What the check proves is what runs.
TLA+, Quint, pnueli and stifinder check the same kind of program, but on a model that you write and keep matching the code yourself.
Case studies
Section titled “Case studies”Two accounts, one lock per account, and a transfer that locks the sender first, then the receiver:
import { Mutex } from 'async-mutex';
export const locks = { alice: new Mutex(), bob: new Mutex() };
export async function transfer(from: 'alice' | 'bob', to: 'alice' | 'bob') { await locks[from].runExclusive(() => locks[to].runExclusive(() => { // move the money }), );}Two transfers start at the same time, Alice to Bob and Bob to Alice. The first one holds Alice and waits for Bob. The second one holds Bob and waits for Alice. Both wait forever, and every next transfer waits behind them. A test that starts both at once shows it:
it('two transfers at once both finish', async () => { await Promise.all([transfer('alice', 'bob'), transfer('bob', 'alice')]);}, 1000); × two transfers at once both finish 1005msError: Test timed out in 1000ms.Tests rarely catch this, the one-line fix (lock in a fixed order) works only while the code is this small, and with locks, pool slots and permits used together in ten places by five people it stops working.
In libpetri
Section titled “In libpetri”This is what you write instead. The step body is still an async function; the lines around it say what the step takes and what it returns.
import { PetriNet, Transition, and, one, outPlace, place } from 'libpetri';
export const alice = place<string>('alice');export const bob = place<string>('bob');export const done = place<string>('done');
function transfer(from: typeof alice, to: typeof alice) { const waiting = place<string>(`${from.name} to ${to.name}`);
// The fix: one step takes both locks at once, or waits for both. const lockBothAndMove = Transition.builder(`${from.name} to ${to.name}: lock both, move`) .inputs(one(waiting), one(from), one(to)) .outputs(and(outPlace(from), outPlace(to), outPlace(done))) .action(async (ctx) => { // move the money, then free both locks ctx.output(from, ctx.input(from)); ctx.output(to, ctx.input(to)); ctx.output(done, ctx.input(waiting)); }) .build();
return { waiting, steps: [lockBothAndMove] };}
export const aliceToBob = transfer(alice, bob);export const bobToAlice = transfer(bob, alice);
export const net = PetriNet.builder('transfers') .transitions(...aliceToBob.steps, ...bobToAlice.steps) .build();The check is a normal vitest test:
const result = await SmtVerifier.forNet(net) .initialMarking((b) => b.tokens(aliceToBob.waiting, 1).tokens(bobToAlice.waiting, 1).tokens(alice, 1).tokens(bob, 1)) .property(deadlockFree()) .sinkPlaces(alice, bob, done) .verify();For this net the check returns “proven”. Written as two steps like the plain code, it returns the two steps to the hang. Both nets and the test are in formal-methods-samples/libpetri-transfer.
A checkout gives the order API five seconds, then tries again:
export async function placeOrder(api: Api, cart: string, timeoutMs = 5_000) { try { return await withTimeout(api.createOrder(cart), timeoutMs); } catch { return await api.createOrder(cart); // the first call may still finish: two orders }}The timeout gives up on the first call, but the API is still working on it. The retry makes a second order, and the first one lands a moment later. A test with a slow API shows it:
it('one checkout, one order', async () => { const { api, orders } = fakeApi(30); await placeOrder(api, 'cart-1', 10); await new Promise((r) => setTimeout(r, 50)); expect(orders).toHaveLength(1);}); × one checkout, one order 99msAssertionError: expected [ 'cart-1', 'cart-1' ] to have a length of 1 but got 2Tests miss this when the API is fast, which is every test run but the one in production at 3 am.
In libpetri
Section titled “In libpetri”The checkout is three steps: send, retry after the timeout, and the API creating the order. The API call is its own step because the second order is made there.
import { PetriNet, Transition, and, delayed, one, outPlace, place } from 'libpetri';import type { Api } from './api.js';
export const requests = place<string>('requests');export const waiting = place<string>('waiting');export const pending = place<string>('pending');export const key = place<string>('key'); // the idempotency key, not used yetexport const orders = place<string>('orders');export const ignored = place<string>('ignored');
export function checkoutNet(api: Api, timeoutMs: number) { const send = Transition.builder('send') .inputs(one(requests)) .outputs(and(outPlace(waiting), outPlace(pending), outPlace(key))) .action(async (ctx) => { ctx.output(waiting, ctx.input(requests)); ctx.output(pending, ctx.input(requests)); ctx.output(key, ctx.input(requests)); }) .build();
const retry = Transition.builder('retry') .inputs(one(waiting)) .outputs(outPlace(pending)) .timing(delayed(timeoutMs)) .action(async (ctx) => { ctx.output(pending, ctx.input(waiting)); }) .build();
// Creating an order uses up the key. const create = Transition.builder('create') .inputs(one(pending), one(key)) .outputs(outPlace(orders)) .action(async (ctx) => { ctx.output(orders, await api.createOrder(ctx.input(pending))); }) .build();
// A call with a used key makes no order. const duplicate = Transition.builder('duplicate') .inputs(one(pending)) .inhibitor(key) .outputs(outPlace(ignored)) .action(async (ctx) => { ctx.output(ignored, ctx.input(pending)); }) .build();
return PetriNet.builder('checkout').transitions(send, retry, create, duplicate).build();}The check: one checkout never makes more than one order.
const result = await SmtVerifier.forNet(m.checkoutNet(fakeApi(0).api, 10)) .initialMarking((b) => b.tokens(m.requests, 1)) .property(placeBound(m.orders, 1)) .verify();For this net the check returns “proven”. Written without the key, as the plain code works, it returns the steps to
the second order: send, retry, create, create. Running that net with a slow API makes two orders, as the plain
code does.
The point that is easy to miss: the fix is not in the checkout. It is the API making one order per key, and the check can prove it only because the API’s side is in the net too. Both nets and the tests are in formal-methods-samples/libpetri-retry.
A search box sends a request on every keystroke and shows whatever comes back:
export function createSearch(fetchResults: (query: string) => Promise<string[]>) { const state = { query: '', results: [] as string[] };
async function onInput(query: string): Promise<void> { state.query = query; const results = await fetchResults(query); state.results = results; }
return { state, onInput };}Type “rea”, then “react”. If the reply for “rea” is slower, it lands last. The box says “react”, the list shows “rea”:
it('the list matches the box', async () => { const delay = { rea: 30, react: 10 }; // the reply for "rea" is slower const search = createSearch(async (q) => { await new Promise((r) => setTimeout(r, delay[q as keyof typeof delay])); return [`results for ${q}`]; }); await Promise.all([search.onInput('rea'), search.onInput('react')]); expect(search.state.results).toEqual([`results for ${search.state.query}`]);}); × the list matches the box 35msAssertionError: expected [ 'results for rea' ] to deeply equal [ 'results for react' ]In libpetri
Section titled “In libpetri”The net is two steps, send and reply, and it can be written in a minute. The check cannot. libpetri has eight checks, and every one counts tokens: nothing hangs, a place never holds more than N, two places never both hold one, a marking is never reached. This is what it can prove here:
const result = await SmtVerifier.forNet(searchNet(async () => [])) .initialMarking((b) => b.tokens(typed, 2)) .property(placeBound(inFlight, 2)) .verify();“Proven”: never more than two requests in flight. True, and useless. The rule that matters, the list matches the box, compares what the reply carries with what the user typed last, and the check does not see what a token carries. There is no way to write it.
Quint states the rule in one line and finds the order that breaks it. The plain code and the net are in formal-methods-samples/libpetri-search.
Conclusion
Section titled “Conclusion”How libpetri compares with the larger, well-known tools for the same job: it is a niche tool, and the others are the better choice, for several reasons.
- A separate model is not only a cost: it helps you think, and writing it is where you find the design mistakes. In the model you are free to use any data types you want and to leave out what does not matter. And the code stays free too: you write it with the tools your team already knows. A model can check any rule. libpetri cannot: its net is runtime code.
- TLA+ and Quint are far more widespread, more useful as modeling tools, and far more production ready.
- TLA+ covers far more: any system as state and next state, protocols, agreement between nodes, and rules about what must eventually happen. There is nothing libpetri can check that TLA+ cannot; every Petri net can be written in TLA+ and checked by TLC.
Comparison of TLA+ and Petri nets:
Both can handle:
- Two transfers that each hold one account and wait for the other.
- A connection pool that must never give out more connections than it has.
- A job that must never run twice at the same time.
- A report that waits for several fetches and must not start before all of them are done.
- A worker pool with a fixed number of permits, never more running at once.
Only TLA+ can handle:
- A rule about a value: a balance never goes negative, a discount never exceeds the price.
- A fencing token: the store rejects a write whose number is older than one it already took.
- A late reply from a retry that overwrites newer data.
- A crash in the middle of a step, and what the restart sees.
- “Every request eventually gets an answer”, not only “nothing bad happens”.
For AI agents: the author uses it for that, it runs Otto’s commerce assistant, and the check covers more of an agent loop than the case study shows. Timeouts and retries of LLM calls, branching on what the model said, and waiting for a person are all paths the check explores, as things that may or may not happen, the same way a TLA+ model treats them. Neither knows how long anything takes, unless you add a clock to the TLA+ model. One thing a TLA+ model can cover and libpetri’s check cannot is a crash and restart in the middle of a run.
Two real systems on this site, the document workflow and the SQS clone, show the same thing. In both, the rules that matter are about values: which of two results is newer, how many times a message was received against a limit that can change. libpetri’s check does not see values. Both also have rules about what must eventually happen, which libpetri does not have. For both, TLA+ or Quint is the right tool. libpetri is not.
- libpetri on GitHub: https://github.com/debe/libpetri
- libpetri on npm: https://www.npmjs.com/package/libpetri