Skip to content

Cookbook: Testing race conditions

Eugene Lazutkin edited this page Jul 30, 2026 · 1 revision

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.

The pattern

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.

The trap: drain with waitFor, not waitAll

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.

Prove the harness explores interleavings

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 counterexample

If 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.)

Replaying a failure

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.

Clone this wiki locally