Writing Engineering & games

You cannot test a timeout by waiting for it

Suppose you have a task that should give up after thirty seconds. How do you test it?

The obvious answer is to wait thirty seconds. Do that a few dozen times across a test suite and your suite takes ten minutes, fails intermittently on a loaded machine, and gets skipped.

The better answer is to stop letting your code read the clock.

The platform gives you almost nothing

I build on Polytoria, which scripts in Luau and provides the usual low-level primitives: spawn, wait, and pcall. That is enough to start something. It is not enough to manage anything.

With only those three, there is no handle to cancel a task, no way to time one out, no way to say “run these five, stop at the first failure,” no bound on how many run at once, and no guarantee that a task’s resources get released when the round it belonged to ends. Every game ends up reimplementing some of this badly, usually with a boolean flag called cancelled that half the call sites forget to check.

TaskRuntime is the managed layer I built on top: cancellable handles, timeouts, retries with backoff, composition, bounded concurrency, cleanup scopes, and diagnostics.

Cancellation has to be cooperative, and that has to be documented

There is no safe way to kill a running Luau coroutine from outside. Whatever the API looks like, cancellation is a request, not a command.

TaskRuntime uses cancellation tokens. A task receives a token, and the task is responsible for checking it — either directly or by using token:Wait() instead of a bare wait, so that a cancellation interrupts the sleep rather than being noticed after it.

runtime:spawn(function(token)
    while token:Wait(0.1) do
        updateSystem()
    end
end)

That loop stops promptly when its token is cancelled. A loop written with wait(0.1) and a flag check at the top stops eventually. The difference shows up when you are trying to tear down a round and something keeps running for another tick.

Scopes, so cleanup is not a checklist

The failure I see most often is not a leaked task. It is a leaked everything else — a connection, an instance, a queue — because teardown was written as a list of things to remember.

local roundScope = TaskRuntime.scope("Round")

roundScope:Every(30, function(token)
    saveDirtyProfiles()
end)

roundScope:Delay(300, function(token)
    endRound()
end)

roundScope:Destroy("round ended")

Destroying the scope cancels its tasks and releases every resource registered against it. The unit of cleanup is the scope, not the individual task, which means adding a new timer to a round does not also require remembering to cancel it somewhere else.

Virtual time

Back to the thirty-second timeout. TaskRuntime ships a virtual-time scheduler for tests. Instead of sleeping, the test advances a clock the runtime believes in. A timeout that fires after thirty seconds of virtual time fires immediately in wall-clock terms, and it fires deterministically — the same interleaving every run, regardless of machine load.

This is the single highest-leverage thing in the library. Async bugs are usually ordering bugs, and ordering bugs that only appear under load are close to untestable. Controlling the clock turns a flaky integration test into a deterministic unit test.

Practical note: if your async code calls a global clock or sleep function directly, it cannot be tested deterministically. Injecting the scheduler is the change that makes everything else testable, and it is much cheaper to do early.

When the platform fights you

One detail worth recording, because it shaped the design. On current Polytoria builds, pcall callbacks execute in a nested Lua thread, and yielding from that nested thread can destabilize the process in yield-heavy systems.

That is an awkward constraint for a task library, because wrapping callbacks in pcall is exactly how you turn a thrown error into a structured failed result instead of a crash. TaskRuntime’s answer is an opt-in adapter that runs callbacks directly in the scheduler thread, avoiding the nested-thread yield entirely.

The trade is explicit and documented: direct mode does not wrap callbacks, so a thrown error escapes rather than becoming a failed handle, and retry is unavailable because retries require catching errors. Existing users stay in protected mode unless they opt in.

I would rather ship two honest modes with a stated trade-off than one mode that quietly works most of the time.

What transfers

  • Cancellation is cooperative whatever your API pretends. Make tokens explicit.
  • Bind cleanup to a scope, not to a programmer’s memory.
  • Inject the clock. Determinism is worth more than realism in tests.
  • When a platform constraint forces a compromise, publish the compromise.

TaskRuntime is MIT licensed and on GitHub.

← All writing Get in touch →