NEP 19: Portable time#
Created: 2026-08-23 · Status: Implemented
Summary#
nupp.time supplies one monotonic clock, one wall clock and one suspension-aware sleep across native and browser hosts. Every in-process deadline is an absolute value on that monotonic basis, so HTTP timeouts, process timeouts and task deadlines are comparable rather than three independently defined notions of now. One bounded timer source serves every wait.
Goals#
- Give ordinary applications a portable clock and a cancellable timer wait.
- Make every in-process deadline comparable on one monotonic time base.
- Park under a handler and sleep efficiently under the blocking driver without changing the call surface.
- Let Tecs poll timers without sleeping or surrendering its SDL event loop.
- Use one bounded timer source rather than one native timer or thread per wait.
Non-goals#
- Calendar arithmetic, time zones, locale formatting or civil-time parsing.
- A duration type or new literal syntax.
- Deciding what a deadline does when it expires. NEP 18 owns cancellation policy; this proposal owns the clock the policy reads.
- Preempting Lua, worker or foreign code when a deadline passes.
- Choosing a frame rate or telling SDL when it must render.
- Making a sleep inside a worker lane useful; it remains blocking work in that isolated state.
Motivation#
Nupp needs monotonic time in more than one place already: the process backend measures child deadlines through its own now, the HTTP provider measures request deadlines through another, and the browser host exposes a third surface with now, wallTime and sleep. Adding task deadlines alongside them without consolidation would make related operations incomparable and duplicate timer policy a fourth time.
A timeout is also the smallest general cancellation source. HTTP and process options cover those exact operations, but an application needs to bound a whole structured scope, wait between retries and run periodic work without inventing a process or request solely to acquire a clock.
Overview and specification#
Public time#
The public module has three operations, keeping the signatures the browser provider already publishes:
local time = require("nupp.time")
function time.now(): number
function time.wallTime(): number
function time.sleep(milliseconds: number): nilnow()returns monotonic milliseconds from an unspecified process origin, suitable for intervals and deadlines rather than persistence.wallTime()returns Unix milliseconds and may move when the system clock is adjusted.sleep(milliseconds)waits for at least that duration, parking under a suspension handler and using the blocking source path otherwise.
Milliseconds are the unit HTTP, processes and the browser provider already use. The value is a number rather than an integer, so a caller may express fractions and a host honors only the resolution it has. A non-finite or negative duration raises, as the browser provider's does today, and no epoch or precision beyond monotonic ordering is promised for now.
sleep(0) returns without parking. It is not a yield point, does not consume an aggregate turn budget, and is not a cancellation checkpoint; tasks.checkpoint() is.
One monotonic provider#
The native host exports one monotonic clock used by nupp.time, HTTP, process deadlines and the worker scheduler. Provider-specific state machines may cache one reading during a poll pass without defining their own epoch or clock contract.
Timer source#
One lazy timer manager holds pending waits ordered by absolute monotonic deadline. Its single readiness source is registered on first use.
The nonblocking half reads now, removes every due timer and resumes its subscription, while the blocking half sleeps no longer than the earlier of its supplied maximum and the next timer deadline. Cancellation removes a timer from the manager rather than leaving a dead entry waiting for its original time.
The manager is bounded by an authored maximum number of pending timers, past which sleep raises rather than growing a process queue without limit. A task deadline is a value rather than a registration and is compared where cancellation is already decided, so it reaches the manager only while a scope is parked with no earlier wakeup, and entering a scope cannot fail against this bound.
Under an installed handler only the nonblocking source is used. A Tecs frame calls suspension.poll() at its authored poll point and never reaches the timer source's sleeping half, because hosts do not call source waits. A later SDL adapter may use the next timer as an idle-wait hint, but fixed-frame correctness does not depend on it.
Lowering#
Time calls are ordinary module calls: reaching nupp.time selects the target's time provider and timer source, and a target that reaches no clock or deadline carries neither.
Risks and assumptions#
- Wall time is not monotonic. Publishing it beside
nowmay invite misuse; separate names and units are the defense. - Timer polling follows the host. A game that polls once per frame observes a timer on the first poll after its deadline, not through an interrupt in the middle of simulation.
- Milliseconds as
numberinvite resolution assumptions. A host that cannot honor a fraction rounds it, since the contract is a lower bound on the wait rather than an exact one. - Migrating private clocks crosses several providers, and keeping compatibility adapters too long would preserve the duplication this proposal removes.
- One timer manager is shared policy. It must remain allocation-stable while polling and avoid rebuilding its order for every insertion.
Alternatives considered#
Keep timeouts inside each I/O library. Already works for HTTP and process, but leaves application scopes, retries and periodic work without a timer and multiplies monotonic-clock contracts.
Use wall time for deadlines. It makes a clock adjustment shorten or extend a live timeout; deadlines use monotonic time and wall time is for timestamps.
Create one native timer per sleep. Hands cancellation and bounds to the platform, at the cost of an unbounded native resource family and a different integration path on every host, where one readiness source composes with the protocol already in place.
Expose only sleep. Avoids clock misuse but forces HTTP, processes and task scopes to keep private clocks, where publishing the two distinct questions makes the shared contract explicit.
Keep deadline policy here, beside the clock. Deadline inheritance, the math.min clamp and expiry share a handle, a status vocabulary and a cancellation identity with task scopes, and nothing with the clock except a reading. The policy moved to NEP 18 and left the reading behind.