Skip to content

Commit f5f71eb

Browse files
committed
test(timer): cover owned resources, callback lifecycle, and docs
- `tests/timer_host_tests.rs`: lifecycle/limits/shutdown coverage plus 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`. A callback-result matrix proves that `int`/`string`/`map`/`bool`/`null`-returning callback bodies compile against the standard catalog and run to completion with their result discarded. - `tests/typed_host_no_dynamic_contract_tests.rs`: the typed-catalog guard now carries the narrow discarded-callable-result policy exception -- a callable result may be `Unknown` because the receiving host discards it -- pinned to the timer callback and proven narrow, since a `Map` result, a nested `Unknown` result, an `Unknown` parameter, and dynamic roots stay rejected under the same policy. - `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 06d206d commit f5f71eb

5 files changed

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

0 commit comments

Comments
 (0)