Skip to content

Commit e641e0f

Browse files
committed
test(timer): cover owned resources, callback lifecycle, and docs
- `tests/timer_host_tests.rs`: lifecycle/limits/shutdown coverage plus new Drop-probe tests proving the private callback VM is released exactly once on normal completion, backend rejection, error rounds, and cancellation. It also covers the downstream adapter API: a seconds-based adapter registered under its own exact host name and schema through `register_owned_timer` (shared admission limits, isolated callback VM, rollback on rejection and panic, missing-runtime reporting, callback type validation) and count functions built on `installed_timer_counts`. - `tests/timer_host_arch_tests.rs`: source-level guard that the timer module and the generic owned-dispatch primitives stay out of every prohibited VM-core/compiler path. - Documentation: `docs/standard-timer-host.md` (contract, typed callback surface, callback driving, capacity MUSTs, downstream adapter usage) and the `README.md` link.
1 parent e15c378 commit e641e0f

4 files changed

Lines changed: 2827 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The complete language, runtime, and implementation guides live on the [RustScrip
1313
- [VM and compiler internals](https://rustscript.org/docs/reference/rustscript/internals/)
1414
- [RSS language](https://rustscript.org/docs/reference/rss/)
1515
- [Host functions](https://rustscript.org/docs/reference/host-functions/)
16+
- [Standard timer host module](docs/standard-timer-host.md)
1617
- [Runtime controls and artifacts](https://rustscript.org/docs/reference/runtime-controls/)
1718
- [Compiler frontend syntax and feature support](src/compiler/frontends/README.md)
1819

‎docs/standard-timer-host.md‎

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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

Comments
 (0)