- Status: Draft
- Tier: Stable
- Author: AI assistant review
- Created: 2026-07-21
- Resolved: (pending)
- Language-version at effect: 2.0 (planned)
- Supersedes: none
- Superseded by: none
Make time a first-class Stable language effect. Add Timer.sleep and Timer.sleep_until as Stable effects available in any actor context, and provide syntactic sugar after duration_ms => expr and until condition => expr for common temporal patterns. Timers are durable by default: a sleeping actor that crashes or is hibernated resumes when the timer fires.
Nulang is repositioning as a durable computation language for long-lived software entities. Time is a fundamental primitive for such entities: delays, deadlines, timeouts, scheduled work, and polling loops appear in almost every durable program. Currently, Timer.sleep exists but is only available inside workflow actors and is not treated as a Stable language primitive. This RFC elevates durable time to a Stable language effect and gives it readable syntax.
Time also appears in the user's proposed strategic updates (e.g., after, until, temporal syntax). By making these constructs sugar over a small set of Stable Timer effects, we keep the language kernel small while giving programmers a natural way to express temporal behavior.
Two operations become Stable language effects:
effect Timer {
sleep(duration_ms: Int) -> Unit
sleep_until(timestamp_ms: Int) -> Unit
}
Semantics:
perform Timer.sleep(duration_ms)suspends the current actor behavior for at leastduration_msmilliseconds, then resumes at the next available scheduler turn.perform Timer.sleep_until(timestamp_ms)suspends until the absolute timestamp (milliseconds since the Unix epoch) is reached.- Both are durable: the runtime records the timer in persistent storage. If the actor crashes, is restarted, or is hibernated, it resumes when the timer fires.
- The return value is
Unit. - Calling either outside an actor context is a runtime error (the standalone VM has no scheduler to arm timers).
after ms => expr is syntactic sugar for receive {} after ms => expr. This is
already a fully working Nulang expression that uses the durable
ReceiveWait suspension mechanism. No additional AST nodes, bytecode
opcodes, or runtime changes are required.
after 5000 => perform IO.print("done")
desugars to:
receive {} after 5000 => perform IO.print("done")
The expression's type is the type of the body.
until self.ready => perform IO.print("ready!")
desugars to a polling loop that yields the actor between checks:
let poll_interval_ms = 100 in
rec loop() {
if self.ready then
perform IO.print("ready!")
else {
perform Timer.sleep(poll_interval_ms)
loop()
}
}()
Rules:
- The condition is re-evaluated after each
Timer.sleep. - The default poll interval is 100 ms; it can be overridden with an explicit interval:
until self.ready poll 50 => expr. untilconsumes one actor turn per evaluation of the condition plus one per sleep. It is not a busy-wait.- Because the loop is explicit, the actor can process other messages between iterations. (In the current single-threaded-per-actor model, this means the behavior must complete; a future
awaitextension could allow preemption insideuntil.)
after and until require the Timer effect in the enclosing function/behavior's effect row:
behavior wait_then_greet() ! {Timer, IO} {
after 1000 => perform IO.print("hello")
}
The typechecker desugars first, then infers the Timer effect from the perform Timer.sleep calls.
For tests and simulation, the runtime provides a deterministic clock effect Timer.now_ms (or a separate Clock effect):
effect Clock {
now_ms() -> Int
}
This is Planned and not part of this RFC. Tests for after/until should mock Timer.sleep with a zero-duration handler so the test completes synchronously.
Signal.wait(name) remains a workflow-specific runtime effect for durable workflow synchronization. It is not elevated to Stable language status. Temporal effects (Timer.sleep, after, until) are the Stable time primitives; signals remain a workflow/library concern.
src/lexer.rs: reserveuntilas a keyword (already done asTokenKind::Until).afterremains a contextual keyword — recognized only inreceiveposition and inparse_afterexpression parsing.src/parser.rs:afteris handled contextually; when seen as an expression prefix, it desugars toreceive {} after ... => ...internally.- No new AST nodes, typechecker cases, effect checker cases, HIR/bytecode
lowering, or runtime changes are needed — the existing
ReceiveWaitinfrastructure handles the suspension, timer, and effect row. untilis reserved asTokenKind::Untilbut its parsing and lowering are Planned (see RFC 0007 for event-sourcing infrastructure thatuntilmay use).
behavior fetch_with_timeout(url: String) ! {Timer, Net, IO} {
let fetch_task = spawn Fetcher { url = url }
after 5000 => {
send fetch_task cancel()
Error("timeout")
}
}
If the actor crashes during the 5-second wait, the timer is restored on recovery and the timeout still fires.
behavior wait_for_payment(order_id: Int) ! {Timer, Storage, IO} poll 200 {
until self.paid => {
let status = perform Storage.read(order_id)
if status == "paid" then
self.paid = true
}
}
Note: the explicit poll 200 overrides the default 100 ms interval.
- Tier: Stable.
- Frozen Core impact: None. Time effects are outside Core.
- Breaking change: No. Existing
Timer.sleepbehavior is preserved; it is generalized from workflow-only to any actor. - Deprecation interaction: None.
This RFC is additive. Existing programs using Timer.sleep in workflow actors continue to work. The runtime change to allow Timer.sleep in any actor is a relaxation, not a breaking change.
- Make
after/untilFrozen Core primitives. Rejected because the Frozen Core excludes actors, effects, and time. - Implement
after/untilas runtime opcodes instead of sugar. Rejected because it duplicates the effect system; sugar overTimer.sleepkeeps the language smaller and easier to formalize. - Make
untila language-level blocking wait on arbitrary conditions without polling. Rejected because it requires either a runtime subscription mechanism (too specific to today's platforms) or implicit preemption (future work). The polling-loop desugaring is explicit and portable. - Use
Timer.nowfor absolute deadlines instead ofsleep_until. Rejected becausesleep_untilis a single durable timer;nowis useful but belongs to a separateClockeffect.
- Should
Timeralso includenow_msin this RFC, or should clock reading be a separateClockeffect? - What is the default poll interval for
until? 100 ms is proposed as a reasonable starting point. - Should
after/untilbe allowed outside actor behaviors (e.g., in top-level__main)? The standalone VM has no scheduler, so this would be a runtime error; should it be a compile-time error instead? - Should
untilsupport a maximum wait time to avoid infinite loops?
(To be filled on accept/reject.)