A drift-free countdown on an STM32MP157F-DK2, tested in milliseconds

Counting down by subtracting the tick interval drifts on an embedded board. Deriving the remaining time from a monotonic deadline instead makes it drift-free by construction — and injecting the clock turns a 25-minute test into a map update. It also caught a real race.

The whole test suite for this firmware runs in a tenth of a second. Sixteen of those tests exercise a Pomodoro timer, including one that runs a full 25-minute work phase to completion and another that runs a work phase, a break, and back again.

There is no Process.sleep in any of them.

The obvious way is wrong

Here is the shape almost everyone writes first:

def handle_info(:tick, state) do
remaining = state.remaining_ms - 250
Process.send_after(self(), :tick, 250)
{:noreply, %{state | remaining_ms: remaining}}
end

Subtract the tick interval every tick. It is the obvious reading of "count down".

It is also wrong, and it is wrong in a way that a desktop hides and an embedded board does not. Process.send_after(self(), :tick, 250) does not mean "in exactly 250 milliseconds". It means "in at least 250 milliseconds, once the scheduler gets round to you". On two Cortex-A7s that are also rendering a UI in software, handling network interrupts and running the rest of OTP, the actual interval is 250-and-a-bit. Every time.

Each tick loses a little. The error only accumulates. Over a 25-minute pomodoro, at 250ms ticks, that is 6,000 opportunities to drift. And the failure is invisible in testing on a fast laptop and obvious on the device, which is the worst possible combination.

The deeper problem is that the timer's state is a running total of its own mistakes. There is nothing to correct against.

Ask the clock instead

Do not store elapsed time. Store the moment you started, and subtract:

defp settle(%{status: :running} = s) do
elapsed = s.clock.() - s.started_at
remaining = max(s.remaining_ms - elapsed, 0)
if remaining == 0 do
completed = if s.phase == :work, do: s.completed + 1, else: s.completed
%{s | status: :finished, remaining_ms: 0, started_at: nil, completed: completed}
else
%{s | remaining_ms: remaining, started_at: s.clock.()}
end
end
defp settle(s), do: s

Every handler calls settle/1 before doing anything else, so reads and writes always see the same truth. Ticks no longer compute anything — they exist purely to trigger a redraw. A tick that arrives late, early, twice, or not at all cannot make the clock wrong, because the clock is not derived from ticks.

That also means the tick interval becomes a pure display decision. Want a smoother sweep on the progress ring? Tick faster. Want to save power? Tick slower. Neither changes what time it is.

The part that makes it testable

Look again at that snippet. The current time comes from s.clock.() — a function in the state, not a direct call to System.monotonic_time/1.

def init(opts) do
clock = Keyword.get(opts, :clock, &default_clock/0)
tick_ms = Keyword.get(opts, :tick_ms, @default_tick_ms)
...
end
defp default_clock, do: System.monotonic_time(:millisecond)

In production that is the monotonic clock. In tests it is an Agent I move by hand:

defp start_clock do
{:ok, agent} = Agent.start_link(fn -> 0 end)
{agent, fn -> Agent.get(agent, & &1) end}
end
defp advance(agent, ms), do: Agent.update(agent, &(&1 + ms))

And now a 25-minute pomodoro takes no time at all:

test "a finished work phase increments completed and moves to break" do
{agent, clock} = start_clock()
pid = start_pomodoro(clock)
Pomodoro.toggle(pid)
advance(agent, @work_ms)
assert %{status: :finished, phase: :work, completed: 1, remaining_ms: 0} =
Pomodoro.state(pid)
end

advance(agent, @work_ms) is 25 minutes of wall-clock time expressed as a map update. The test finishes in microseconds.

Better still, it tests things that are awkward to test with real time. A clock that jumps forward an hour because NTP corrected it:

test "a large clock jump does not overshoot past zero" do
Pomodoro.toggle(pid)
advance(agent, @work_ms * 10)
assert %{remaining_ms: 0} = Pomodoro.state(pid)
end

Try provoking that with Process.sleep.

Sleep is a smell

A Process.sleep in a test is almost always one of two things: waiting for time to pass, or waiting for a message to arrive. Both are avoidable, and both are worth avoiding, because a sleeping test is slow and flaky — too short and it is non-deterministic, too long and the suite crawls.

There is exactly one Process.sleep in this suite, in the vitals test, waiting on a real GenServer's real 20ms poll interval. That one is honest: it is not simulating time, it is waiting for another process to do its job. Everything else injects.

It caught a real bug immediately

Worth telling on myself here. When the timer was first written, toggle, reset and adjust were GenServer.cast. Fire and forget, no reply needed, which felt right.

Seven of sixteen tests failed, deterministically.

The reason is a genuinely instructive race. cast returns immediately, before the server has processed anything. The test then does this:

Pomodoro.toggle(pid) # cast — returns instantly
advance(agent, 60_000) # move the clock

Nothing guarantees the server read started_at from the clock before the test moved it. Sometimes the timer started at t=0, sometimes at t=60000. Same code, different answer.

The fix is call — the client blocks until the server has actually read the clock and updated its state, which is exactly the ordering the test needs. Same function names, same arities, still returns :ok. No deadlock risk, because the timer never calls back into its callers; subscriber updates are plain send.

And this is the useful part: that race was real, not a test artifact. In production the same pattern means a tap on START and the next redraw can disagree about when the timer began. The injected clock did not invent the bug. It made a real one deterministic enough to notice.

What it bought

The timer knows nothing about Scenic. No graph, no viewport, no driver, no framebuffer. It is a GenServer holding a map, and every rule about counting down — pause and resume, adjust while running, clamping at 1 and 60 minutes, incrementing the completed count on work phases but not breaks, cycling work to break and back — is verified on a laptop with no hardware attached, in a tenth of a second.

That is not test-suite vanity. It means when I finally flash this to the board and something is wrong, I already know it is not the timer. On embedded, where the feedback loop is a firmware build and a reboot, narrowing the search space before you start is most of the work.


Next: first light. Flashing v1.4.0, the rotation problem, and calibrating a touchscreen that disagrees with your coordinate system.