Readable Asynchronous State Machines with Lua Coroutines
Lua 5.4 coroutines are cooperative. The running coroutine explicitly yields, and some scheduler resumes it later. A coroutine does not provide threads, an event loop, asynchronous network I/O, or preemption by itself. The examples use Lua 5.4.8 and stay within the stable 5.4 coroutine API. With a small protocol in which yielded values describe what a task is waiting for, a linear function can express a state machine that would otherwise be spread across callbacks.
Yield an intention, not an implementation
Business workflow code should not know which timer wheel or socket library the host uses. It can yield a table describing the wait, and the scheduler can interpret that table:
local function wait_seconds(seconds)
return coroutine.yield({ kind = "sleep", seconds = seconds })
end
local function workflow(order)
order.state = "validating"
wait_seconds(0.1)
if not order.valid then
return nil, "invalid order"
end
order.state = "submitting"
local receipt = coroutine.yield({
kind = "request",
method = "POST",
path = "/orders"
})
order.state = "completed"
return receipt
end
The function reads sequentially, but every yield is an explicit state boundary. The first resume supplies ordinary function arguments. A later resume(thread, value) makes value the return value of the suspended yield. The scheduler can therefore inject a network response, timeout, or test double without the workflow calling a real transport.
Keep the yielded protocol small and versioned. A sleep might have a deadline, a request might carry an operation identity, and a message wait might name a channel. If each feature invents an arbitrary table, the scheduler becomes a collection of implicit conventions rather than a dependable interface.
Inspect every result from resume
coroutine.resume does not throw an error from inside the coroutine directly into its caller. It returns false and an error object. Ignoring the first result can accidentally treat a failure as application data. A minimal driver distinguishes failure, suspension, and completion:
local function step(task, input)
local values = table.pack(coroutine.resume(task.thread, input))
if not values[1] then
task.state = "failed"
task.error = values[2]
return
end
if coroutine.status(task.thread) == "dead" then
task.state = "finished"
task.result = values[2]
else
task.state = "waiting"
task.waiting_for = values[2]
end
end
A real implementation should preserve multiple return values, attach a useful traceback, and validate every yielded intention. An unknown kind should fail clearly instead of leaving a task suspended forever. The workflow may be application code, but plugin or configuration code can still request an excessive delay, unauthorized path, or malformed operation. The host remains responsible for limits.
Cancellation is an explicit protocol
Coroutines do not automatically have parent-child cancellation. Removing a task from a run queue merely ensures it is never resumed; cleanup and to-be-closed variables may not get a chance to run. Lua 5.4 provides coroutine.close, which lets a host close a suspended or failed coroutine and inspect the result. The application must still define when cancellation happens, who owns each resource, and whether an outstanding external request can be withdrawn.
A simple policy stores a cancellation flag on the task, checks it before each resume, unregisters timers and I/O watchers, and finally closes the coroutine. External effects that already happened do not roll back when a coroutine closes. Requests need idempotency identifiers or the workflow needs compensating actions. A coroutine is not a transaction.
If a parent starts child tasks, record that relationship. Parent cancellation should have a documented effect on children, and process shutdown should enumerate all remaining work. Otherwise “background tasks” become orphans with no monitoring or cleanup owner.
Separate expected outcomes from defects
An invalid order can be an ordinary domain result. Indexing a nil value is a program error. Do not compress both into one string channel. The workflow may return nil, domain_error for an expected rejection, while the false result from resume represents an unexpected failure that receives a traceback and task context.
Useful diagnostics include the task identity, current waiting intention, latest transition, and traceback. They must not include authentication secrets or full sensitive payloads. A task record can also retain timestamps and transition counts, allowing the host to detect work that is waiting too long or yielding in an accidental busy loop.
Test with virtual time
A unit test should not actually wait 100 milliseconds. Step the task, assert that it yielded a sleep intention, advance a virtual clock, then resume it with a fake response. Cover successful completion, domain rejection, an unknown intention, accidental resume of a dead coroutine, cancellation, and resource cleanup.
Yield points are observable checkpoints, which makes this architecture highly testable. If a workflow test still depends on a public network or real wall-clock delay, the scheduler and application concerns have not been fully separated.
Lua coroutines can make asynchronous state machines linear and readable, but the surrounding system still needs scheduling, validation, failure reporting, cancellation, and resource ownership. Let a coroutine describe waits, let the host perform controlled effects, and drive tests with virtual time. That makes coroutine a clear workflow mechanism rather than a magical layer that hides lifecycle problems.