-
-
Notifications
You must be signed in to change notification settings - Fork 0
Cookbook: Testing race conditions
Schedulers and concurrency-control primitives are interleaving-sensitive by definition: a bug can hide in one specific completion order out of hundreds. Property-based race testing with fast-check's cooperative scheduler explores those interleavings systematically. time-queues uses this technique for its own test suite (tests/test-race-limited-queue.js, tests/test-race-batch.js) via tape-six-fast-check; the recipe below applies to any queue-like system.
Hand your tasks to the queue as s.schedule(...)-wrapped promises, so fast-check decides the completion order while the queue decides what starts when:
import test from 'tape-six';
import fc from 'fast-check';
import 'tape-six-fast-check';
import LimitedQueue from 'time-queues/LimitedQueue.js';
test('cap never exceeded, drains to completion', async t => {
await t.prop(
[fc.scheduler(), fc.integer({min: 1, max: 4}), fc.integer({min: 0, max: 12})],
async (s, limit, n) => {
const queue = new LimitedQueue(limit);
let high = 0,
completed = 0;
for (let i = 0; i < n; ++i) {
queue.enqueue(() => {
high = Math.max(high, queue.activeTasks);
return s.schedule(Promise.resolve(), `task ${i}`).then(() => ++completed);
});
}
await s.waitFor(queue.waitForIdle());
return high <= limit && completed === n;
},
'activeTasks never exceeds the limit and every task completes'
);
});Sample concurrency invariants at task start — the only instants the active count increases.
Do not write await s.waitAll() followed by await queue.waitForIdle() — it deadlocks.
s.waitAll() resolves as soon as no scheduled tasks remain at the moment it checks. A queue that launches its next task from a .then / .finally continuation registers that task's s.schedule(...) several microtask hops away from the released promise (each thenable adoption adds hops). waitAll can settle inside that gap; the follow-up task is then scheduled but never released, and any outcome that depends on it (waitForIdle(), a batch() result) waits forever.
s.waitFor(outcome) keeps releasing scheduled tasks until the outcome promise settles, at any hop depth — it is the correct drain for every system that schedules more work as earlier work completes. waitAll is fine only for flat task sets that schedule everything up front.
A race suite that cannot fail is vacuous. Before trusting green properties, assert a deliberately false invariant and watch fast-check refute it:
// completions are NOT FIFO on a concurrency-2 queue — this property must fail
const queue = new LimitedQueue(2);
const completions = [];
for (let i = 0; i < 4; ++i) {
queue.enqueue(() => s.schedule(Promise.resolve(), `t${i}`).then(() => completions.push(i)));
}
await s.waitFor(queue.waitForIdle());
return completions.every((v, j) => v === j); // fast-check finds a counterexampleIf the false invariant survives, the setup is not actually varying the interleavings. (Task start order, by contrast, is genuinely FIFO for LimitedQueue — that is one of the real properties in the suite.)
A failed property reports its shrunk counterexample, seed, and path as structured assertion data; pin them in the options to replay the exact case. See the tape-six-fast-check README for details.
Queues
Utility Functions
Supporting Classes
Base Classes
Random Utilities
Page Load Helpers
Cookbook
Built on list-toolkit