Timer Runtime Semantics
This document contains the runtime semantics decisions for Shield timers, sleep, and fork.
Current implementation status: timer_once/timer/cancel_timer are implemented; each timer is driven by a CAF driver actor (delayed_send + tick_atom), which sends timer_fire_atom to the owning service actor on expiry; the callback executes as a coroutine at the service actor's scheduling point via invoke_coroutine (it may suspend with shield.sleep / shield.call without blocking the actor), and errors route to the on_error hook. shield.sleep is coroutine-aware (yield + _resume_after timer resume). shield.fork is implemented; the task body likewise executes as a coroutine. shield.call/call_timeout implement coroutine-aware calls + timeout. For detailed implementation status, the Refactoring Roadmap and the Lua API Tests prevail.
AD-07 layered clocks are implemented: shield.now() returns wall-clock UTC ms (injectable MockClock, test-settable), shield.monotonic() returns real steady_clock ms (not settable). The no-argument forms of os.time()/os.date() are redirected to the business clock; os.time(table)/os.date(fmt,t)/os.clock() keep native behavior. LuaServiceManager::attach_clock() injects the clock; Clock/SystemClock/MockClock are defined in include/shield/lua/clock.hpp. test_lua_api_clock covers 12 cases.
timer API
local id = shield.timer_once(1000, function()
end)
local id = shield.timer(1000, function()
end)
local ok = shield.cancel_timer(id)Rules:
- The timer callback executes as a coroutine in the current service's Lua VM (wrapped by
invoke_coroutine); errors route to theon_errorhook. - Inside the callback,
shield.sleep/shield.callsuspend the callback coroutine and the actor immediately returns to the mailbox to process other messages; the existing resume mechanism continues after the callback finishes. - Timers belong to the current service.
- On service exit, owned timers are cancelled automatically.
TimerIdis an opaque userdata; CAF is not exposed.
timer Time Semantics
timer_once(delay_ms)
timer(interval_ms)Rules:
delay_ms >= 0.interval_ms > 0.- Layered time (AD-07): timer firing is based on the monotonic clock (InfraClock, not settable), consistent with CAF
delayed_send.shield.now()/os.time()(no args) /os.date()(no args) read by Lua business go through the injectable wall-clockClock(LuaClock, settable in tests).os.clock()is not wired in (keeps real CPU time). See Architecture Decision AD-07. - Periodic timers use fixed-cadence semantics: the next tick advances as "previous fire time + interval_ms", independent of callback execution duration.
fixed-cadence:
the driver actor re-arms the next tick's delayed_send immediately after dispatching this tick's fire;
next tick time = this tick's fire time + interval_ms- Slower callback execution neither shifts the cadence base nor accumulates missed ticks for catch-up — ticks already past are not re-fired.
- The drift cap of the actual fire time relative to the theoretical tick is the fire message's dispatch delay (the driver-side recorded
next_fire_msadvances each tick to "current time + interval_ms"; the comment's words: drift equals the fire-dispatch lag). - Fixed-rate is not used, avoiding catch-up pileup from slow callbacks — the motivation is unchanged; only the cadence base is fixed and does not shift with callback completion time.
timer Error Semantics
When a timer callback raises an error:
- The error routes via
invoke_error_hookto the service'son_error(err, context)hook withcontext.type = "timer", and atimer error: ...ERROR log is recorded. timer_onceends after recording the error (single fire; the entry is removed after firing).timerkeeps firing on cadence after recording an error and does not stop automatically — the implementation has no "stop on error" logic; what can stop it are an explicitcancel_timer, service exit (auto-cancels owned timers), or the panic exit below.- Reaching the consecutive error threshold (
kDefaultMaxErrorsBeforePanic, currently 10; one successful dispatch resets the counter) triggerson_panicandexit("panic"). - Errors feed
shield_opsstatistics (Phase 2, not yet implemented).
Because periodic timers keep running on error, business code only needs its own pcall when it wants certain errors not to spam on_error/counting.
shield.sleep
shield.sleep(1000)Semantics:
- In a coroutine context (message handler, timer/fork callback, on_init, on_shutdown) only the current coroutine is suspended.
- Only in coroutine-less contexts such as module-level code does it degrade to the thread-blocking
_block_sleep. - The coroutine path does not block the runtime thread.
- Implemented on top of timers.
- On service exit, sleeping coroutines are cancelled.
- Business code needing non-blocking waits should call it inside service handlers.
sleep(0) yields the current coroutine to the tail of the service ready queue.
shield.fork
shield.fork posts a background task owned by the current service to the owning service actor (executed at the actor's scheduling point via fork_task_atom); it does not create a new service.
local task_id = shield.fork(function()
shield.send("player", "tick")
end)Rules:
- A fork task shares the Lua VM and Lua state with the current service.
- A fork task has no service id, no mailbox, and no name.
- The fork task executes as a coroutine (wrapped by
invoke_coroutine);shield.sleep/shield.callsuspend the task coroutine inside it without blocking the actor. - Forks scheduled inside
on_initexecute serially only after init completes: the actor stashesfork_task_atomduring init and unstashes them uniformly atinit_ready— a fork never enters the same Lua VM concurrently with the spawning thread. - Fork unhandled errors are logged and counted in ops metrics; they do not kill the service.
- On service exit, owned fork tasks are cancelled automatically.
Task ID
shield.fork currently returns a numeric task id.
local task_id = shield.fork(fn)The current contract provides no task cancel, status, or join, avoiding extra wait graphs and deadlock semantics. When a result is needed, use a service call or a business channel.