Back to Ideas 6 min read

The Clock That Learned to Walk

E
EkoHacks Team
·
The Clock That Learned to Walk

EkoLite, our small real time backend, keeps a fake clock. Not for production, where time is the operating system's job, but for tests, where time has to be something a test can hold in its hand. When the shutdown policy arms a five second deadline, no test wants to wait five real seconds to watch it fire. So the nulled ProcessWrapper carries a stub clock, and a test moves time by calling advanceTime(5000) instead of sleeping.

A stub like that is a small promise. It promises that time, inside the test, behaves the way time behaves outside it. Break the promise quietly and you get the worst kind of test: one that passes for reasons that have nothing to do with the code it claims to cover. This is the story of a stub clock that made two promises, broke one of them in a way no test could see, and what it took to make it walk instead of jump.

What a fake clock owes you

The real setTimeout makes two guarantees worth naming. Timers fire in deadline order: a timer due at 10ms fires before one due at 20ms, whatever order you scheduled them in. And time passes through every moment on the way: a timer scheduled from inside another timer's callback takes its deadline from the moment that callback ran, not from some later point the clock has already reached.

The stub's first version honoured neither cleanly. It fired timers in the order they were scheduled, so a later call could run before an earlier deadline. That is exactly the sort of bug a refactor is supposed to surface, and this one did. The fix sorted the due timers by deadline before firing them, and a new test pinned the order down. Good work as far as it goes.

But look at the shape of that fix, because the second promise is hiding inside it.

advanceTime(ms: number): void {
  this.now += ms;

  const dueTimers = this.timers
    .filter((timer) => timer.live && timer.dueAt <= this.now)
    .sort((left, right) => left.dueAt - right.dueAt);

  for (const timer of dueTimers) {
    timer.live = false;
    timer.callback();
  }

  this.timers.splice(0, this.timers.length, ...this.timers.filter((timer) => timer.live));
}

The first line moves the whole clock to the end of the window. advanceTime(100) sets now to 100 before a single callback has run. Then it works out which timers are due and fires them, in deadline order now, which is the win. But time did not pass. Time teleported.

The timer that arrived too late to its own party

Here is the promise that broke. Ask the stub to fire a timer that schedules another timer.

it('fires a timer scheduled by another timer, when both come due in the same advance', () => {
  const proc = ProcessWrapper.createNull();
  const order: number[] = [];

  proc.startTimer(10, () => {
    order.push(10);
    proc.startTimer(5, () => order.push(15));
  });

  proc.advanceTime(100);

  expect(order).toEqual([10, 15]);
});

A ten millisecond timer whose callback schedules a five millisecond one. Under the real clock the outer fires at 10ms, and the inner, measured from there, fires at 15ms, comfortably inside a hundred millisecond window. The test expects [10, 15].

The stub returns [10]. The inner timer never fires. And the reason is the teleport. By the time the outer callback runs, now is already 100. startTimer(5) reads that now and gives the new timer a deadline of 105. The window ended at 100. The timer was born five milliseconds after the world it lives in had stopped. It sits there, live and unfired, waiting for a future this advance already ran past.

Notice what kind of failure this is. Nothing throws. The suite is green. It stays green precisely because nothing in the codebase yet schedules a timer from inside a timer, so the broken promise costs nothing today and everything the day someone writes a retry that re arms a deadline. The test that would have caught it is the test nobody had a reason to write.

Teaching the clock to walk

The fix is not another special case. It is to stop teleporting. A real clock passes through every instant between here and the target, and it fires each timer at the instant it comes due, with now actually sitting at that instant while the callback runs. So the stub should walk the same road: find the earliest timer that is due, move now to exactly its deadline, fire it, and look again. Timers scheduled by that callback are measured against a truthful now, and the next lap of the loop picks them up if they land inside the window.

advanceTime(ms: number): void {
  const target = this.now + ms;

  while (true) {
    const nextTimer = this.timers.reduce<StubbedTimer | null>((earliest, timer) => {
      if (!timer.live || timer.dueAt > target) {
        return earliest;
      }
      if (earliest === null || timer.dueAt < earliest.dueAt) {
        return timer;
      }
      return earliest;
    }, null);

    if (nextTimer === null) {
      this.now = target;
      break;
    }

    this.now = nextTimer.dueAt;

    const index = this.timers.indexOf(nextTimer);
    this.timers.splice(index, 1);

    nextTimer.callback();
  }
}

Save the target first. Then loop: reach for the earliest live timer due at or before the target, step now to its deadline, take it out of the queue, and let it run. When the callback schedules a new timer, that timer reads the honest now and gets a deadline in the present, not stranded past the end. When nothing is left due, move now to the target and stop.

The nested timer fires now. The outer runs with now at 10, the inner is scheduled for 15, the loop comes back round, finds it inside the window, and fires it with now at 15. [10, 15], the same answer the real clock gives.

And the walk quietly dissolves two things the teleport had needed. The deadline sort is gone, because taking the earliest timer on every lap is the ordering. The separate cleanup line that pruned fired timers is gone too, because a timer leaves the queue the moment it fires. One honest mechanism did the work of three careful ones. That is usually the sign you have found the right depth: the special cases do not get handled, they stop existing.

Why this is the whole game for us

We run on real work read closely. An engineer takes a refactoring story on EkoLite, the pull request comes back, and the review reads the reasoning under the diff as much as the diff itself. The first version of this clock was not careless. It found a real ordering bug and pinned it with a test, which is more than most refactors manage. The gap it left was a promise nobody had written a test for, and the review's job was to write that test, watch it go red, and point at the mechanism rather than dictate the lines. That review, and the missing question it kept turning up across the same pull request, is The line nothing could see.

The rest was the engineer's. The drain loop above is their answer to a red test, not a patch copied from a comment. That is the difference we care about. A verdict teaches one fix. A failing test and a nudge toward the mechanism teaches the question you can carry to the next stub, the next clock, the next promise you were tempted to keep by teleporting past it.

The move to take home

When you fake a resource, list the promises the real one makes before you make the real one's job easier. A clock promises order and passage. It is easy to buy order with a sort and lose passage to a teleport, because the teleport is simpler to write and the loss is invisible until code leans on it. Model the thing the way it actually behaves, one step at a time, and the invisible cases become ordinary ones. The clock that walks fires every timer the real one would. The clock that jumps fires only the ones you happened to test.

E

Written by

EkoHacks Team

More from Ideas

·6 min read

What the Nullable Gave Back

One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.

E
EkoHacks Team
·6 min read

Twenty Six More Tests, Four Fewer Behaviours

Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.

E
EkoHacks Team
·6 min read

The Best Coverage Number in the Room

Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.

E
EkoHacks Team