|
| 1 | +# Standard timer host module |
| 2 | + |
| 3 | +The standard `timer` module provides four host functions: |
| 4 | + |
| 5 | +- `timer::at(delay_ms, callback)` — register one callback. |
| 6 | +- `timer::every(interval_ms, callback)` — register a repeating callback. |
| 7 | +- `timer::pending_count()` — ask the backend how many registrations are waiting |
| 8 | + to begin or waiting for a running slot. |
| 9 | +- `timer::running_count()` — ask the backend how many callback executions are |
| 10 | + live. A callback paused on an async host operation remains running. |
| 11 | + |
| 12 | +`delay_ms` must be non-negative. `interval_ms` must be positive. The defaults |
| 13 | +are `DEFAULT_MAX_PENDING_TIMERS` (1024) pending registrations and |
| 14 | +`DEFAULT_MAX_RUNNING_TIMERS` (256) concurrently running callbacks; both are |
| 15 | +re-exported at the crate root, and an embedding can install different |
| 16 | +`TimerConfig` limits. |
| 17 | + |
| 18 | +The callback parameter is typed `fn(bool) -> null`: the callback observes the |
| 19 | +`premature` flag and its **return value is discarded**, so the declared result |
| 20 | +is the typed expression of "no result" (a public host catalog carries concrete |
| 21 | +schemas only). A callback body therefore ends with `null` — for example |
| 22 | +`timer::at(25, |premature| null)` or |
| 23 | +`timer::at(25, |premature| if true => { work(); null } else => { null })`. |
| 24 | + |
| 25 | +## Installation |
| 26 | + |
| 27 | +A VM resolves the timer imports as soon as it is bound to a registry that |
| 28 | +composes the standard catalog, but a call fails until backend state is |
| 29 | +installed. Two installation shapes exist: |
| 30 | + |
| 31 | +- Compose the extension — |
| 32 | + `vm.install_extension(&TimerExtension::new(backend, config))?` registers the |
| 33 | + four timer host functions from the standard catalog and installs the |
| 34 | + `TimerBackend` state in one call. |
| 35 | +- Install state directly — `vm.install_timer_runtime(backend, config)` (the |
| 36 | + `TimerHostExt` trait) installs backend state for a VM whose timer functions |
| 37 | + were registered through `register_timer_builtin_module` / |
| 38 | + `register_timer_builtin_module_from_catalog`. |
| 39 | + `vm.clear_timer_runtime()` removes that state again. |
| 40 | + |
| 41 | +Because the standard catalog always exposes the timer imports, a compiled |
| 42 | +program that only registers the functions still resolves them; each call then |
| 43 | +fails at the runtime boundary with the installation error: |
| 44 | + |
| 45 | +```text |
| 46 | +timer runtime is not installed |
| 47 | +``` |
| 48 | + |
| 49 | +`clear_timer_runtime` restores exactly that state: the registered functions |
| 50 | +stay bound, and later calls fail with the same error until a backend is |
| 51 | +installed again. Callback VMs hold only a weak reference to their backend, so |
| 52 | +after the owning backend is dropped an existing callback reports |
| 53 | +`timer runtime has shut down` instead of reusing released state. |
| 54 | + |
| 55 | +## Generic backend boundary |
| 56 | + |
| 57 | +`TimerBackend::register` receives a complete `TimerRegistration`. A successful |
| 58 | +return transfers ownership of the registration and its `OwnedTimerCallback` to |
| 59 | +the backend. A returned error means the backend retained nothing. The backend |
| 60 | +must enforce admission and running limits under its own synchronization, and |
| 61 | +must provide idempotent shutdown. |
| 62 | + |
| 63 | +The generic module has no request, connection, worker-phase, or `ngx.timer` |
| 64 | +semantics. It does not create request objects, inherit request-local state, or |
| 65 | +implement OpenResty scheduling rules. The `premature` boolean is the only |
| 66 | +lifecycle signal supplied to a callback; the embedding defines deadlines, |
| 67 | +worker ownership, shutdown timing, and any surrounding request policy. |
| 68 | + |
| 69 | +## Callback ownership and rollback |
| 70 | + |
| 71 | +The callback parameter is declared `TakeOwned`. Registration follows this |
| 72 | +transactional order: |
| 73 | + |
| 74 | +1. Validate the duration, before taking any argument. |
| 75 | +2. Clone the callback value for rollback, then take the original callback from |
| 76 | + the owned host call. |
| 77 | +3. Validate the callable's complete program-local graph. Cycles are visited once; |
| 78 | + nested foreign callables and resource-bearing captures are rejected. Resource |
| 79 | + captures remain unsupported because this boundary has no resource-table |
| 80 | + transfer operation. |
| 81 | +4. Create a fresh private callback VM, bind the host registry, install the |
| 82 | + timer module state, and adopt the validated callable graph. |
| 83 | +5. Build the registration and call the backend. |
| 84 | +6. Only a successful backend return commits the transfer. |
| 85 | + |
| 86 | +A preflight, VM-spawn, or backend error restores the cloned callback into its |
| 87 | +original host-call slot and marks that slot untaken. The ordinary owned-dispatch |
| 88 | +failure path then restores all untaken arguments to the guest stack exactly |
| 89 | +once. A backend panic follows the same restoration step and then resumes the |
| 90 | +original panic. Consequently a rejected registration does not consume the |
| 91 | +source callback, and a backend rejection must not retain the callback VM. |
| 92 | + |
| 93 | +The fresh callback VM starts halted with no execution frames, stack, or host |
| 94 | +return. Its callable graph remains owned by that VM for the lifetime of the |
| 95 | +registration. Module state, host bindings, and immutable program configuration |
| 96 | +survive a callback reset; source-VM frames, stack, locals, resources, and |
| 97 | +waiting operations are never copied into it. |
| 98 | + |
| 99 | +## Driving callbacks |
| 100 | + |
| 101 | +Backends drive each accepted `OwnedTimerCallback` on the designated VM thread. |
| 102 | +`start(premature)` begins one serialized round and passes the boolean to the |
| 103 | +callback. A callback return value is discarded. `start` rejects overlapping |
| 104 | +`Running`, `Waiting`, or `Yielded` rounds. |
| 105 | + |
| 106 | +Rounds are strictly serialized: an `every` registration schedules its next |
| 107 | +round only after the previous round reaches a terminal state, so a callback |
| 108 | +slower than its interval never overlaps itself. Because every round reuses the |
| 109 | +same callable value, mutable capture cells persist from one round to the next. |
| 110 | + |
| 111 | +For synchronous error handling, inspect the `VmResult` returned by `start` and |
| 112 | +`poll`. For a backend-owned reporting path, use `start_reporting` and |
| 113 | +`poll_reporting`: |
| 114 | + |
| 115 | +- `start_reporting(premature, backend)` starts a round and sends a start error |
| 116 | + to `report_callback_error`. |
| 117 | +- `poll_reporting(cx, backend)` polls one waiting/resumable step and sends an |
| 118 | + async poll or resume error to the same sink. It returns `Pending` while the |
| 119 | + callback is still waiting and returns the callback's terminal/live state when |
| 120 | + ready. |
| 121 | + |
| 122 | +Raw `poll` remains available when the embedding wants to handle errors itself. |
| 123 | +Every returned poll/resume error has already moved the callback to `Complete` |
| 124 | +and recovered its private VM before the error is returned. `poll_reporting` |
| 125 | +therefore reports one failure for that round; a second poll of the completed |
| 126 | +callback does not report the same failure again. |
| 127 | + |
| 128 | +### Async host calls and the per-callback bridge |
| 129 | + |
| 130 | +Every accepted callback owns a private VM, so async host work inside a callback |
| 131 | +never runs on the creating request's bridge. Whenever a callback body can enter |
| 132 | +an async host operation, the backend must install a fresh bridge for that |
| 133 | +callback VM with `OwnedTimerCallback::set_async_bridge` **before** the first |
| 134 | +`start` call — one bridge per callback, never shared with the source VM or with |
| 135 | +another callback. A callback VM without its own bridge cannot suspend on async |
| 136 | +host work. |
| 137 | + |
| 138 | +While the callback waits, `start` and `poll` report `Waiting(op_id)`. The |
| 139 | +backend then drives `poll_reporting(cx, backend)`: it polls one |
| 140 | +waiting/resumable step, returns `Pending` while the operation is still |
| 141 | +outstanding (re-poll when the bridge wakes the task), and returns the |
| 142 | +callback's terminal or live state once the step is ready. Use `poll_reporting` |
| 143 | +for callback rounds that can wait; it routes async poll/resume failures to |
| 144 | +`report_callback_error` without touching the creating request VM. |
| 145 | + |
| 146 | +Callback start, waiting poll, and resume each have a panic boundary. A callback |
| 147 | +panic is converted to a structured `VmError::HostError`, reported through the |
| 148 | +reporting helper when used, and followed by the same graph-preserving reset. |
| 149 | +The callback becomes `Complete`, so a repeating registration can attempt its |
| 150 | +next round. Backend registration panics are separate: the source argument is |
| 151 | +restored and the backend panic is preserved for the embedding to handle. |
| 152 | + |
| 153 | +For `at`, the backend should remove the registration after the callback reaches |
| 154 | +`Complete` or `Cancelled`. For `every`, a callback error is reported for that |
| 155 | +round, the callback is reset for reuse, and the registration remains eligible |
| 156 | +for later rounds. A later successful round uses the same callable capture cells. |
| 157 | +Shutdown should stop accepting registrations, pass `premature=true` to pending |
| 158 | +callbacks, run each of them exactly once while the backend can still execute |
| 159 | +them, cancel active waiting operations, release every callback VM, and never |
| 160 | +reschedule another `every` round. |
| 161 | + |
| 162 | +## Downstream adapters: the same path under another name |
| 163 | + |
| 164 | +An embedding that exposes this contract under its own exact host name and |
| 165 | +schema — a seconds-based, `ngx.timer`-shaped host, for example — does not |
| 166 | +re-implement the callback handoff. Three public items cover it: |
| 167 | + |
| 168 | +- `HostOwnedFunction`, `OwnedHostCall`, `OwnedHostContext` (re-exported at the |
| 169 | + crate root) — the adapter implements `HostOwnedFunction` and receives the |
| 170 | + drained call; |
| 171 | +- `timer::register_owned_timer(call, registry, callback_arg, delay, interval)` — |
| 172 | + takes the owned call, the index of the callable argument, and a **checked** |
| 173 | + `Duration` plus an optional repeating `Duration`, and performs exactly the |
| 174 | + steps `timer::at` / `timer::every` perform: runtime lookup, callback type and |
| 175 | + program-provenance validation, fresh isolated callback VM, admission limits |
| 176 | + carried from the installed `TimerConfig`, backend registration, and the |
| 177 | + transactional rollback of the callback argument on a returned error or a |
| 178 | + panic. It rejects a zero repeating interval and never inspects the host name, |
| 179 | + so it is name-independent and unit-independent; |
| 180 | +- `timer::installed_timer_counts(vm)` — returns `TimerCounts { pending, running }` |
| 181 | + read synchronously from the installed backend, so count functions never need |
| 182 | + `TimerBackend` or `TimerHostState`. |
| 183 | + |
| 184 | +```rust |
| 185 | +impl HostOwnedFunction for MySecondsTimer { |
| 186 | + fn call(&mut self, call: &mut OwnedHostCall<'_>) -> VmResult<CallOutcome> { |
| 187 | + let seconds = match call.arg(0) { |
| 188 | + Some(Value::Int(seconds)) => *seconds, |
| 189 | + _ => return Err(VmError::TypeMismatch("timer seconds")), |
| 190 | + }; |
| 191 | + if seconds < 0 { |
| 192 | + return Err(VmError::HostError("timer seconds must be non-negative".into())); |
| 193 | + } |
| 194 | + register_owned_timer( |
| 195 | + call, |
| 196 | + &self.registry, |
| 197 | + TIMER_CALLBACK_ARG, |
| 198 | + Duration::from_secs(seconds as u64), |
| 199 | + None, |
| 200 | + ) |
| 201 | + } |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +`OwnedTimerCallback` values are only ever created inside `register_owned_timer` |
| 206 | +and the two millisecond adapters, so a downstream adapter can never construct |
| 207 | +one directly and bypass callback provenance or VM isolation. |
| 208 | + |
| 209 | +## Running-limit policy |
| 210 | + |
| 211 | +`TimerRegistration.max_running` is a runtime-wide concurrent cap. A callback in |
| 212 | +`Waiting` counts as running. When the cap is full, due callbacks remain pending; |
| 213 | +they are not discarded and they are not started concurrently. Once a running |
| 214 | +callback reaches a terminal state or is cancelled, the backend may admit the |
| 215 | +next pending callback. This leave-pending policy applies to both one-shot and |
| 216 | +repeating registrations and is part of the standard backend contract. |
| 217 | + |
| 218 | +## Capacity: who enforces the limits |
| 219 | + |
| 220 | +The generic module performs **no admission and keeps no counters**. Each |
| 221 | +`TimerRegistration` carries the installed `TimerConfig` limits (`max_pending`, |
| 222 | +`max_running`), and enforcing them is a documented **MUST** on the backend: |
| 223 | + |
| 224 | +- the backend checks a limit and performs its own registration/scheduling under |
| 225 | + the same synchronization, so `max_pending` / `max_running` are hard caps |
| 226 | + rather than post-hoc observations; |
| 227 | +- a rejected registration returns an error and retains nothing, and the generic |
| 228 | + module restores the callback to the caller (see the rollback contract above); |
| 229 | +- `timer::pending_count()`, `timer::running_count()`, and |
| 230 | + `installed_timer_counts` are pure backend queries: they return exactly |
| 231 | + `TimerBackend::pending_count()` / `TimerBackend::running_count()` for the |
| 232 | + installed runtime. The module never substitutes a module-local estimate, and |
| 233 | + it exposes no global or process-wide timer state. |
| 234 | + |
| 235 | +Embeddings therefore select their own capacity by installing a `TimerConfig` |
| 236 | +(with the documented defaults `DEFAULT_MAX_PENDING_TIMERS` = 1024 and |
| 237 | +`DEFAULT_MAX_RUNNING_TIMERS` = 256); a backend that needs a stricter or |
| 238 | +dynamically shared budget enforces it inside its own `register` / |
| 239 | +scheduling path. |
| 240 | + |
| 241 | +## VM-core boundary |
| 242 | + |
| 243 | +The timer surface is composed from `src/builtins/runtime/mod.rs` and |
| 244 | +re-exported from `src/lib.rs`; `tests/timer_host_arch_tests.rs` guards the |
| 245 | +boundary. The only VM-core seam is the *generic* owned-value dispatch in |
| 246 | +`src/vm/host.rs` (`HostOwnedFunction`, `OwnedHostCall`, `register_exact_owned`, |
| 247 | +`OwnedHostContext`, `OwnedHostCall::spawn_owned_callable_vm`, and |
| 248 | +`Vm::recover_owned_callable`), which carries no timer domain term. Owned |
| 249 | +dispatch restores every argument the handler did not take exactly once on every |
| 250 | +failure path — a host error, a panic, a rejected `Yield`, and a rejected return |
| 251 | +alike — keeps taken arguments consumed, and never rewinds the instruction |
| 252 | +pointer for a retry. Because the dispatch drains the operands before the |
| 253 | +handler runs, an owned handler returning `Yield` is rejected as a structured |
| 254 | +host error. |
| 255 | + |
| 256 | +One adaptation note for this revision: the public owned-dispatch view types |
| 257 | +(`HostOwnedFunction`, `OwnedHostCall`, `OwnedHostContext`) are defined in the |
| 258 | +`host_api` vocabulary module and re-exported at the crate root, because |
| 259 | +`src/vm`'s module re-export surface is frozen here; the dispatch and every |
| 260 | +VM-coupled operation remain in `src/vm/host.rs`. |
0 commit comments