Skip to content

Commit cf24e8d

Browse files
committed
feat(wasi): #180 ADR-015 S4a — clock_now_ms builtin (preview1 → wasi:clocks via S3)
ADR-015 S4 first slice: clocks. Native WASI clocks via the preview1 import surface (the adapter S3 wraps it with bridges to preview2 `wasi:clocks` on a real host). - `lib/wasi_runtime.ml`: `create_clock_time_get_import` + `gen_clock_now_ms` (emits `clock_time_get(clock_id, 0_precision, scratch)` → drops errno → loads i64 ns from scratch → `/ 1_000_000` → wrap to i32 ms; ~24-day wrap, documented). - `lib/codegen.ml`: builtin special-case `clock_now_ms(clock_id) -> Int` (mirrors `print`'s `ExprVar`-name pattern). **Conditional import on use**: a pre-scan via `Effect_sites.fold_calls` adds the `wasi_snapshot_preview1.clock_time_get` import iff the unit calls `clock_now_ms` — units that don't use it are byte-identical to pre-S4a (zero LinkError surface for existing harnesses; the "import what you use" principle). Idx 1 after fd_write at 0, deterministic (cross-module imports from gen_imports follow); type indices are computed via `List.length`, so the +1 in ctx.types is a uniform shift (safe — verified by the gate). Hardcoded `clock_func_idx = 1` matches the assembly. - `lib/typecheck.ml` register_builtins + `lib/resolve.ml` builtin list: `clock_now_ms : Int -> Int / { Time }` (`Time` is reserved; tracking only, like the rest of the v1 effect system). Tests: - `tests/codegen/clock_now_ms.{affine,mjs}` — host stubs `clock_time_get` writing 5_000_000_000 ns, asserts the guest returns 5000 (the ns/1_000_000 arithmetic + import wiring + CLOCK_MONOTONIC threading). V8 passes wasm i64 to JS as **BigInt** (3 params, not 4-split-i32) — load-bearing. - The S3 componentize smoke + S3 path on the clock fixture: the component is structurally valid, the `affinescript.ownership` section survives, wasmtime loads it. Real-host main invoke through wasi:clocks needs WIT export lifting / `wasi:cli/run` command shape — that's S6, NOT S4 (the S4 contract is correct preview1 emission; the adapter does the preview2 bridge). Gate: `dune test --force` 295/295; full `tools/run_codegen_wasm_tests.sh` PASSED incl. the new test (the critical proof that the import-space shift didn't break anything — print, #199 closure ABI, #234 effect-table, #225 CPS, all green). S3 smoke still PASSED. Zero regression. Refs #180 — S4a done; next S4b (env+argv, same preview1-import pattern), S5 filesystem (unblocks INT-06), S6 flip default. Not Closes.
1 parent bbe6ebc commit cf24e8d

8 files changed

Lines changed: 191 additions & 10 deletions

File tree

docs/ECOSYSTEM.adoc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,15 @@ valid WASI-0.2 component via the fetch-pinned preview1→preview2
213213
`affinescript.ownership` section is proven to SURVIVE the wrap;
214214
`tests/componentize/smoke.sh` gates it (SKIP-safe without the
215215
toolchain). Codegen UNCHANGED — core preview1 stays the default
216-
(reversible).** Next: S4 native clocks/env/argv. WIT world of
216+
(reversible). S4a (clock) DONE: `clock_now_ms(clock_id)` builtin
217+
lowers to a `wasi_snapshot_preview1.clock_time_get` import (added
218+
on-demand via Effect_sites pre-scan; idx 1 after fd_write at 0;
219+
zero impact on units that don't use it). Component-path bridges to
220+
`wasi:clocks` via the reactor adapter; the component is
221+
structurally valid + ownership-preserving (real-host invocation
222+
deferred to S6: requires WIT export-lifting / wasi:cli command
223+
shape). Next: S4b env+argv (same preview1-import pattern), then
224+
S5 filesystem, S6 flip default.** WIT world of
217225
record: `wit/affinescript.wit`
218226
|INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |runtime
219227
packaging READY (affine-js + affinescript-tea JSR dry-run green;

docs/TECH-DEBT.adoc

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,14 @@ follow-up
185185
multi-ns import object, ownership accessor); 14 unit tests via pinned
186186
`deno task test` + `tests/modules/loader-bridge/` e2e on real
187187
compiler-emitted xmod wasm (closes INT-01↔INT-02). Unblocks INT-05/08/11
188-
|INT-03 |WASI preview2 / host I/O |S1→S3 |#180 ADR-015 (full
188+
|INT-03 |WASI preview2 / host I/O |S1→S4a |#180 ADR-015 (full
189189
Component-Model re-target, S1..S6); S2 toolchain #251 closed;
190-
**S3 DONE — `tools/componentize.sh` (pinned reactor adapter) →
191-
valid WASI-0.2 component, ownership section survives,
192-
`tests/componentize/smoke.sh` gates it; codegen unchanged. Next S4
190+
S3 componentize on-ramp done; **S4a (clock) DONE —
191+
`clock_now_ms(clock_id)` builtin lowers to a preview1
192+
`clock_time_get` import (Effect_sites pre-scan, on-demand → zero
193+
regression on non-clock units); component path bridges to
194+
wasi:clocks. Real-host main-invoke deferred to S6 (WIT export
195+
lifting). Next S4b
193196
(native clocks/env/argv)**
194197
|INT-04 |Publish to JSR/npm |S2 |#181 packaging READY (dry-run green,
195198
manual workflow); JSR publish authorised + dispatched (owner go

lib/codegen.ml

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,27 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
832832
in
833833
Ok (ctx_with_heap, print_code)
834834

835+
| ExprVar id when id.name = "clock_now_ms" && List.length args = 1 ->
836+
(* ADR-015 S4a (#180): clock_now_ms(clock_id) — i32 monotonic /
837+
realtime milliseconds. Lowers to a `wasi_snapshot_preview1.
838+
clock_time_get` call (import idx 1, hardcoded to match the
839+
module-assembly ordering). Under the S3 component path the
840+
reactor adapter bridges this to wasi:clocks on a real
841+
preview2 host. Effect row is `Time` (tracking-only). *)
842+
let* (ctx_with_arg, arg_code) = gen_expr ctx (List.hd args) in
843+
let (ctx_with_arg2, clock_arg_local) =
844+
alloc_local ctx_with_arg "__clock_id" in
845+
let (ctx_with_scratch, scratch_local) =
846+
alloc_local ctx_with_arg2 "__clock_scratch" in
847+
let (ctx_with_heap, heap_idx) = ensure_heap_ptr ctx_with_scratch in
848+
let clock_func_idx = 1 in
849+
let code =
850+
arg_code @ [LocalSet clock_arg_local] @
851+
Wasi_runtime.gen_clock_now_ms
852+
heap_idx clock_arg_local scratch_local clock_func_idx
853+
in
854+
Ok (ctx_with_heap, code)
855+
835856
| ExprVar id when List.mem_assoc id.name ctx.variant_tags ->
836857
(* Enum constructor called as a function: Circle(5), Rect({x:1,y:2}), etc.
837858
Layout: [tag: i32][field1: i32][field2: i32]...
@@ -2467,11 +2488,39 @@ let generate_module ?loader (prog : program) : wasm_module result =
24672488
let fd_write_type_idx = 0 in (* Will be first type *)
24682489
let fd_write_import_fixed = { fd_write_import with i_desc = ImportFunc fd_write_type_idx } in
24692490

2470-
let ctx_with_wasi = {
2471-
ctx with
2472-
types = fd_write_type :: ctx.types;
2473-
imports = fd_write_import_fixed :: ctx.imports;
2474-
} in
2491+
(* ADR-015 S4a (#180): register WASI `clock_time_get` only when the
2492+
unit actually calls `clock_now_ms` — adding it unconditionally
2493+
would force every host (incl. every test harness) to stub it,
2494+
breaking the principle "import what you use". Pre-scan via the
2495+
shared Effect_sites traversal. When emitted, the clock takes
2496+
import idx 1 (deterministically after fd_write at 0), which the
2497+
`clock_now_ms` builtin hardcodes. *)
2498+
let needs_clock =
2499+
Effect_sites.fold_calls
2500+
(fun acc _ord call ->
2501+
acc ||
2502+
match call with
2503+
| ExprApp (ExprVar id, _) -> id.name = "clock_now_ms"
2504+
| _ -> false)
2505+
false prog
2506+
in
2507+
let ctx_with_wasi =
2508+
if needs_clock then
2509+
let (clock_import, clock_type) = Wasi_runtime.create_clock_time_get_import () in
2510+
let clock_type_idx = 1 in
2511+
let clock_import_fixed = { clock_import with i_desc = ImportFunc clock_type_idx } in
2512+
{
2513+
ctx with
2514+
types = fd_write_type :: clock_type :: ctx.types;
2515+
imports = fd_write_import_fixed :: clock_import_fixed :: ctx.imports;
2516+
}
2517+
else
2518+
{
2519+
ctx with
2520+
types = fd_write_type :: ctx.types;
2521+
imports = fd_write_import_fixed :: ctx.imports;
2522+
}
2523+
in
24752524

24762525
let* ctx_with_imports = gen_imports loader prog.prog_imports ctx_with_wasi in
24772526

lib/resolve.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ let seed_builtins (symbols : Symbol.t) : unit =
5353
let def name = let _ = Symbol.define symbols name SKFunction Span.dummy Public in () in
5454
(* Console I/O *)
5555
def "print"; def "println"; def "eprint"; def "eprintln";
56+
(* WASI time (ADR-015 S4a, #180) *)
57+
def "clock_now_ms";
5658
(* String / char builtins *)
5759
def "len"; def "slice"; def "string_get"; def "string_sub"; def "string_find";
5860
def "char_to_int"; def "int_to_char"; def "show";

lib/typecheck.ml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,14 @@ let register_builtins (ctx : context) : unit =
13141314
let float_binop = TArrow (ty_float, QOmega, TArrow (ty_float, QOmega, ty_float, EPure), EPure) in
13151315
bind_var ctx "print" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13161316
bind_var ctx "println" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
1317+
(* ADR-015 S4a (#180): WASI clock primitive. `clock_now_ms(clock_id)`
1318+
-> Int (monotonic/realtime milliseconds, clock_id 0=realtime/
1319+
1=monotonic). Tracking effect `Time` (reserved). Lowers to a
1320+
`wasi_snapshot_preview1.clock_time_get` import on the wasm
1321+
target; on the S3 component path the reactor adapter bridges to
1322+
`wasi:clocks`. *)
1323+
bind_var ctx "clock_now_ms"
1324+
(TArrow (ty_int, QOmega, ty_int, ESingleton "Time"));
13171325
bind_var ctx "eprint" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13181326
bind_var ctx "eprintln" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13191327
bind_var ctx "read_line"

lib/wasi_runtime.ml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,65 @@ open Wasm
1313
let fd_stdout = 1l
1414
let fd_stderr = 2l
1515

16+
(** WASI clock-id constants (preview1 `wasi_snapshot_preview1`):
17+
0 = REALTIME, 1 = MONOTONIC, 2 = PROCESS_CPUTIME, 3 = THREAD_CPUTIME. *)
18+
let clock_realtime = 0l
19+
let clock_monotonic = 1l
20+
21+
(** Create the WASI `clock_time_get` import (ADR-015 S4a, #180).
22+
23+
Signature: `(clockid: i32, precision: i64, time_out: i32) -> errno: i32`.
24+
Writes the timestamp (nanoseconds, i64) to `*time_out`. Through the
25+
S3 componentize on-ramp the wasmtime preview1->preview2 reactor
26+
adapter bridges this to `wasi:clocks/{monotonic,wall}-clock@0.2.*`
27+
on a real host. *)
28+
let create_clock_time_get_import () : import * func_type =
29+
let func_type = {
30+
ft_params = [I32; I64; I32]; (* clockid, precision, time_out_ptr *)
31+
ft_results = [I32]; (* errno *)
32+
} in
33+
let import = {
34+
i_module = "wasi_snapshot_preview1";
35+
i_name = "clock_time_get";
36+
i_desc = ImportFunc 0; (* Will be adjusted when added to module *)
37+
} in
38+
(import, func_type)
39+
40+
(** Emit a `clock_now_ms(clock_id)` sequence. The caller has placed
41+
[clock_arg_local] (the `i32` clock id) and allocated
42+
[scratch_local] (an `i32` heap-pointer local) and threaded the
43+
import [clock_func_idx]. Leaves the i32 monotonic/realtime
44+
millisecond count on the stack (= ns / 1_000_000, wrapped to i32 —
45+
documented lossy: 32-bit ms wraps after ~24 days, sufficient for
46+
typical use; the precise i64 reader is a follow-up).
47+
48+
Layout: 8 bytes scratch (the i64 time_out the WASI host writes). *)
49+
let gen_clock_now_ms
50+
(heap_ptr_global : int) (clock_arg_local : int)
51+
(scratch_local : int) (clock_func_idx : int)
52+
: instr list =
53+
[
54+
(* scratch = heap; heap += 8 *)
55+
GlobalGet heap_ptr_global;
56+
I32Const 8l; I32Add;
57+
GlobalSet heap_ptr_global;
58+
GlobalGet heap_ptr_global;
59+
I32Const 8l; I32Sub;
60+
LocalSet scratch_local;
61+
(* clock_time_get(clock_id, 0 /* precision */, scratch); drop errno *)
62+
LocalGet clock_arg_local;
63+
I64Const 0L;
64+
LocalGet scratch_local;
65+
Call clock_func_idx;
66+
Drop;
67+
(* return (i32) (i64_load(scratch) / 1_000_000) *)
68+
LocalGet scratch_local;
69+
I64Load (3, 0);
70+
I64Const 1_000_000L;
71+
I64DivU;
72+
I32WrapI64;
73+
]
74+
1675
(** Create WASI fd_write import
1776
1877
fd_write signature: (fd: i32, iovs: i32, iovs_len: i32, nwritten: i32) -> i32

tests/codegen/clock_now_ms.affine

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// ADR-015 S4a (#180) — wasm-path smoke for `clock_now_ms`, the first
5+
// WASI clocks primitive. The builtin lowers to a
6+
// `wasi_snapshot_preview1.clock_time_get` import (added on-demand via
7+
// the codegen pre-scan; idx 1 right after fd_write); under the S3
8+
// component path the reactor adapter bridges this to `wasi:clocks`.
9+
//
10+
// The harness stubs the import with a known nanosecond value so the
11+
// guest's `ns / 1_000_000` computation is asserted exactly.
12+
13+
pub fn main() -> Int / { Time } {
14+
clock_now_ms(1) // 1 = CLOCK_MONOTONIC
15+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// ADR-015 S4a (#180) — assert the `clock_now_ms` builtin wires its
5+
// `wasi_snapshot_preview1.clock_time_get` import correctly and
6+
// computes `ns / 1_000_000` (wrapped to i32). Host-stubbed: no real
7+
// clock involved (the real-host path is the component smoke,
8+
// tests/componentize/smoke.sh).
9+
import assert from 'node:assert/strict';
10+
import { readFile } from 'node:fs/promises';
11+
12+
const buf = await readFile('./tests/codegen/clock_now_ms.wasm');
13+
let inst = null;
14+
let observed = null; // (clock_id, precision, time_ptr) the guest asked for
15+
16+
const imports = {
17+
wasi_snapshot_preview1: {
18+
fd_write: () => 0,
19+
// Modern V8 passes wasm i64 to JS as BigInt (not two i32s):
20+
// (clock_id: i32, precision: i64-as-BigInt, time_ptr: i32) -> i32.
21+
clock_time_get: (clock_id, _precision, time_ptr) => {
22+
// 5_000_000_000 ns = 5000 ms. Store i64 little-endian at time_ptr.
23+
observed = { clock_id, time_ptr };
24+
const dv = new DataView(inst.exports.memory.buffer);
25+
dv.setBigUint64(time_ptr, 5_000_000_000n, true);
26+
return 0; // errno OK
27+
},
28+
},
29+
};
30+
31+
inst = (await WebAssembly.instantiate(buf, imports)).instance;
32+
const result = inst.exports.main();
33+
34+
assert.ok(observed, 'guest called clock_time_get');
35+
assert.equal(observed.clock_id, 1, 'CLOCK_MONOTONIC threaded through');
36+
assert.equal(result, 5000, 'ns/1_000_000: 5_000_000_000 → 5000 ms');
37+
console.log('test_clock_now_ms.mjs OK');

0 commit comments

Comments
 (0)