Skip to content

Commit db99ed6

Browse files
feat(wasi): #180 ADR-015 S4b — env_count/arg_count + multi-import indexing (#290)
Second S4 slice: env + argv counts via WASI preview1 sizes_get imports. Also a small principled refactor: ctx.wasi_func_indices records each WASI import's func idx, so multi-import-on-use indexing is no longer fragile-hardcoded (S4a's `clock_func_idx = 1` becomes a `List.assoc` lookup; future WASI builtins just append). - `lib/wasi_runtime.ml`: `create_environ_sizes_get_import` + `create_args_sizes_get_import` + shared `gen_count_via_sizes_get` emitter (alloc 8B scratch, call sizes_get(count_ptr, bufsize_ptr), drop errno, return *count). - `lib/codegen.ml`: • `context.wasi_func_indices : (string * int) list` (additive field; default []). • Module-assembly refactored: canonical optional WASI imports list (clock_time_get, environ_sizes_get, args_sizes_get) added on-demand via the existing Effect_sites pre-scan, indices assigned by position (fd_write=0; others 1..). The `clock_now_ms` builtin now looks up its idx by name (no more hardcoded 1) — fully backward-compatible. • `env_count(u: Unit) -> Int / Time` and `arg_count(u: Unit) -> Int / Time` builtin special-cases. Unit arg = the zero-param-fn collapse wart workaround (per existing memory). - `lib/typecheck.ml` + `lib/resolve.ml`: register the two builtins. Tests: - `tests/codegen/env_count.{affine,mjs}` — host stubs environ_sizes_get with count 7; asserts 7. - `tests/codegen/arg_count.{affine,mjs}` — same shape, count 3. - `tests/codegen/wasi_combo.{affine,mjs}` — **the critical regression**: a single unit using ALL three optional WASI imports (clock + env + args). Each lookup in `ctx.wasi_func_indices` must return the right idx; stubbed values chosen so the sum (100+20+3 = 123) uniquely identifies any indexing mistake. PASSED. - `clock_now_ms` (S4a) still passes through the refactor — proves the indexing change is lossless. String accessors (env_at/arg_at) need byte-level wasm IR ops (I32Load8U/I32Store8 absent in lib/wasm.ml today) — tracked follow-up as a small wasm-IR extension PR before/with S5. Gate: `dune test --force` 295/295; full `tools/run_codegen_wasm_tests.sh` PASSED incl. all 4 new tests (print, #199 closure ABI, #234 effect-table, #225 CPS, S4a clock, S4b env/arg/combo, all green). Zero regression. Refs #180 — S4 done (counts); next: byte-ops slice for strings, then S5 wasi:filesystem (unblocks INT-06), then S6 flip default + WIT export lifting. Not Closes.
1 parent 6156d0b commit db99ed6

12 files changed

Lines changed: 299 additions & 44 deletions

docs/ECOSYSTEM.adoc

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,15 +213,16 @@ 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). 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
216+
(reversible). S4a (clock) + S4b (env_count, arg_count) DONE:
217+
builtins lower to on-demand `wasi_snapshot_preview1.*` imports
218+
(Effect_sites pre-scan, canonical-order indexing through
219+
`ctx.wasi_func_indices`; zero impact on units that don't use them;
220+
verified with a multi-import combo regression). Component path
221+
bridges to `wasi:clocks`/`wasi:cli`. Real-host main-invoke deferred
222+
to S6 (WIT export-lifting / wasi:cli/run command shape). String
223+
accessors (env_at/arg_at) gated on a byte-level wasm-IR extension
224+
(I32Load8U/I32Store8 absent today) — tracked as the next slice
225+
before/with S5 filesystem.** WIT world of
225226
record: `wit/affinescript.wit`
226227
|INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |runtime
227228
packaging READY (affine-js + affinescript-tea JSR dry-run green;

docs/TECH-DEBT.adoc

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,15 @@ 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→S4a |#180 ADR-015 (full
188+
|INT-03 |WASI preview2 / host I/O |S1→S4b |#180 ADR-015 (full
189189
Component-Model re-target, S1..S6); S2 toolchain #251 closed;
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
190+
S3 componentize done; **S4a (clock) + S4b (env_count, arg_count)
191+
DONE — on-demand preview1 imports via Effect_sites pre-scan,
192+
canonical-order indexing through `ctx.wasi_func_indices`; combo
193+
regression proves no collision. String accessors (env_at/arg_at)
194+
gated on byte-level wasm IR (I32Load8U/I32Store8 absent today) —
195+
tracked next slice. Real-host main-invoke = S6 (WIT export
196+
lifting). Next S5
196197
(native clocks/env/argv)**
197198
|INT-04 |Publish to JSR/npm |S2 |#181 packaging READY (dry-run green,
198199
manual workflow); JSR publish authorised + dispatched (owner go

lib/codegen.ml

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ type context = {
5656
(** Collected ownership annotations: (func_index, param_kinds, return_kind).
5757
Emitted as the [affinescript.ownership] Wasm custom section for typed-wasm
5858
Level 7/10 verification. Kind encoding: 0=Unrestricted, 1=Linear, 2=SharedBorrow, 3=ExclBorrow. *)
59+
wasi_func_indices : (string * int) list;
60+
(** ADR-015 S4 (#180): WASI preview1 import name → wasm func index.
61+
Populated at module-assembly time from the optional-imports
62+
pre-scan. `fd_write` is always present at 0; other entries
63+
(`clock_time_get`, `environ_sizes_get`, `args_sizes_get`, …) are
64+
added on-demand in a canonical order, with indices computed by
65+
position. WASI builtin special-cases look up their own import
66+
index by name from this map. *)
5967
}
6068

6169
(** Code generation error *)
@@ -100,6 +108,7 @@ let create_context () : context = {
100108
next_string_offset = 2048; (* Start strings after heap at offset 2048 *)
101109
datas = [];
102110
ownership_annots = [];
111+
wasi_func_indices = [];
103112
}
104113

105114
(** Extract ownership kind from a parameter declaration.
@@ -833,26 +842,58 @@ let rec gen_expr (ctx : context) (expr : expr) : (context * instr list) result =
833842
Ok (ctx_with_heap, print_code)
834843

835844
| 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). *)
845+
(* ADR-015 S4a (#180): clock_now_ms(clock_id) -> Int ms. Lowers
846+
to a `wasi_snapshot_preview1.clock_time_get` call; the import
847+
is added on-demand at module assembly (S4b refactor), and the
848+
index lives in [ctx.wasi_func_indices] (no hardcoded idx).
849+
Under the S3 component path the reactor adapter bridges this
850+
to wasi:clocks. Effect row is `Time` (tracking-only). *)
842851
let* (ctx_with_arg, arg_code) = gen_expr ctx (List.hd args) in
843852
let (ctx_with_arg2, clock_arg_local) =
844853
alloc_local ctx_with_arg "__clock_id" in
845854
let (ctx_with_scratch, scratch_local) =
846855
alloc_local ctx_with_arg2 "__clock_scratch" in
847856
let (ctx_with_heap, heap_idx) = ensure_heap_ptr ctx_with_scratch in
848-
let clock_func_idx = 1 in
857+
let clock_func_idx =
858+
try List.assoc "clock_time_get" ctx.wasi_func_indices
859+
with Not_found -> 1 (* defensive; pre-scan guarantees presence *)
860+
in
849861
let code =
850862
arg_code @ [LocalSet clock_arg_local] @
851863
Wasi_runtime.gen_clock_now_ms
852864
heap_idx clock_arg_local scratch_local clock_func_idx
853865
in
854866
Ok (ctx_with_heap, code)
855867

868+
| ExprVar id when (id.name = "env_count" || id.name = "arg_count")
869+
&& List.length args = 1 ->
870+
(* ADR-015 S4b (#180): env_count(u: Unit) / arg_count(u: Unit)
871+
— i32 count returns. Lower to the matching
872+
`wasi_snapshot_preview1.{environ,args}_sizes_get` import
873+
(added on-demand at module assembly; idx looked up in
874+
[ctx.wasi_func_indices]). The Unit arg satisfies the
875+
zero-param-fn collapse wart; it is evaluated but its value
876+
is unused. String accessors (env_at/arg_at) need byte-level
877+
wasm IR ops (currently absent) and are a tracked follow-up. *)
878+
let wasi_name =
879+
if id.name = "env_count" then "environ_sizes_get"
880+
else "args_sizes_get"
881+
in
882+
let sizes_func_idx =
883+
try List.assoc wasi_name ctx.wasi_func_indices
884+
with Not_found -> 1
885+
in
886+
let* (ctx_with_arg, arg_code) = gen_expr ctx (List.hd args) in
887+
let (ctx_with_scratch, scratch_local) =
888+
alloc_local ctx_with_arg ("__" ^ id.name ^ "_scratch") in
889+
let (ctx_with_heap, heap_idx) = ensure_heap_ptr ctx_with_scratch in
890+
let code =
891+
arg_code @ [Drop] @
892+
Wasi_runtime.gen_count_via_sizes_get
893+
heap_idx scratch_local sizes_func_idx
894+
in
895+
Ok (ctx_with_heap, code)
896+
856897
| ExprVar id when List.mem_assoc id.name ctx.variant_tags ->
857898
(* Enum constructor called as a function: Circle(5), Rect({x:1,y:2}), etc.
858899
Layout: [tag: i32][field1: i32][field2: i32]...
@@ -2488,37 +2529,50 @@ let generate_module ?loader (prog : program) : wasm_module result =
24882529
let fd_write_type_idx = 0 in (* Will be first type *)
24892530
let fd_write_import_fixed = { fd_write_import with i_desc = ImportFunc fd_write_type_idx } in
24902531

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 =
2532+
(* ADR-015 S4 (#180): register WASI preview1 imports ON DEMAND so
2533+
non-using units stay byte-identical to pre-S4 (the "import what
2534+
you use" principle — adding unconditionally would force every
2535+
host stub them and break every test harness). Pre-scan with the
2536+
shared Effect_sites traversal: each builtin name maps 1:1 to its
2537+
WASI import; appended after fd_write (idx 0) in a CANONICAL ORDER
2538+
so the index assignment is deterministic across compilations.
2539+
Each builtin's gen_expr case looks up its index by name in
2540+
`ctx.wasi_func_indices` (no hardcoded indices — clean
2541+
multi-import indexing). *)
2542+
let uses (builtin : string) : bool =
24992543
Effect_sites.fold_calls
25002544
(fun acc _ord call ->
25012545
acc ||
25022546
match call with
2503-
| ExprApp (ExprVar id, _) -> id.name = "clock_now_ms"
2547+
| ExprApp (ExprVar id, _) -> id.name = builtin
25042548
| _ -> false)
25052549
false prog
25062550
in
2551+
let optional_wasi =
2552+
(* (guest_builtin_name, wasi_import_name, factory) — canonical order. *)
2553+
[ ("clock_now_ms", "clock_time_get", Wasi_runtime.create_clock_time_get_import);
2554+
("env_count", "environ_sizes_get", Wasi_runtime.create_environ_sizes_get_import);
2555+
("arg_count", "args_sizes_get", Wasi_runtime.create_args_sizes_get_import);
2556+
]
2557+
|> List.filter_map
2558+
(fun (b, w, f) -> if uses b then Some (w, f ()) else None)
2559+
|> List.mapi (fun i (w, (imp, ty)) -> (i + 1, w, imp, ty))
2560+
in
2561+
let opt_types = List.map (fun (_, _, _, ty) -> ty) optional_wasi in
2562+
let opt_imports =
2563+
List.map (fun (idx, _, imp, _) -> { imp with i_desc = ImportFunc idx })
2564+
optional_wasi
2565+
in
2566+
let wasi_indices =
2567+
("fd_write", fd_write_type_idx) ::
2568+
List.map (fun (idx, name, _, _) -> (name, idx)) optional_wasi
2569+
in
25072570
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
25182571
{
25192572
ctx with
2520-
types = fd_write_type :: ctx.types;
2521-
imports = fd_write_import_fixed :: ctx.imports;
2573+
types = fd_write_type :: opt_types @ ctx.types;
2574+
imports = fd_write_import_fixed :: opt_imports @ ctx.imports;
2575+
wasi_func_indices = wasi_indices;
25222576
}
25232577
in
25242578

lib/resolve.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ let seed_builtins (symbols : Symbol.t) : unit =
5555
def "print"; def "println"; def "eprint"; def "eprintln";
5656
(* WASI time (ADR-015 S4a, #180) *)
5757
def "clock_now_ms";
58+
(* WASI env / argv counts (ADR-015 S4b, #180) *)
59+
def "env_count"; def "arg_count";
5860
(* String / char builtins *)
5961
def "len"; def "slice"; def "string_get"; def "string_sub"; def "string_find";
6062
def "char_to_int"; def "int_to_char"; def "show";

lib/typecheck.ml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,13 @@ let register_builtins (ctx : context) : unit =
13221322
`wasi:clocks`. *)
13231323
bind_var ctx "clock_now_ms"
13241324
(TArrow (ty_int, QOmega, ty_int, ESingleton "Time"));
1325+
(* ADR-015 S4b (#180): WASI environment / argv COUNTS. The Unit arg
1326+
satisfies the zero-param-fn collapse wart (`fn()->T` lowers to
1327+
bare `T`; callable zero-arg builtins take `Unit -> R`). String
1328+
accessors (env_at/arg_at) need byte-level wasm IR ops — tracked
1329+
follow-up. Effect row `Time` (reserved). *)
1330+
bind_var ctx "env_count" (TArrow (ty_unit, QOmega, ty_int, ESingleton "Time"));
1331+
bind_var ctx "arg_count" (TArrow (ty_unit, QOmega, ty_int, ESingleton "Time"));
13251332
bind_var ctx "eprint" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13261333
bind_var ctx "eprintln" (TArrow (ty_string, QOmega, ty_unit, ESingleton "IO"));
13271334
bind_var ctx "read_line"

lib/wasi_runtime.ml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,62 @@ let gen_print_str (heap_ptr_global : int) (str_ptr_local : int) (fd_write_idx :
354354
Drop;
355355
I32Const 0l;
356356
]
357+
358+
(** Create the WASI `environ_sizes_get` import (ADR-015 S4b, #180).
359+
Signature: `(envc_out: i32, envbuf_size_out: i32) -> errno: i32`.
360+
Writes the env-var count and the total byte size of the
361+
null-terminated `KEY=VAL\0…` buffer the next call would need.
362+
String accessor (`env_at`) is gated on byte-level wasm IR ops,
363+
deferred to a follow-up slice. *)
364+
let create_environ_sizes_get_import () : import * func_type =
365+
let func_type = {
366+
ft_params = [I32; I32]; (* envc_out_ptr, envbuf_size_out_ptr *)
367+
ft_results = [I32]; (* errno *)
368+
} in
369+
let import = {
370+
i_module = "wasi_snapshot_preview1";
371+
i_name = "environ_sizes_get";
372+
i_desc = ImportFunc 0;
373+
} in
374+
(import, func_type)
375+
376+
(** Create the WASI `args_sizes_get` import (ADR-015 S4b, #180).
377+
Signature: `(argc_out: i32, argv_buf_size_out: i32) -> errno: i32`. *)
378+
let create_args_sizes_get_import () : import * func_type =
379+
let func_type = {
380+
ft_params = [I32; I32];
381+
ft_results = [I32];
382+
} in
383+
let import = {
384+
i_module = "wasi_snapshot_preview1";
385+
i_name = "args_sizes_get";
386+
i_desc = ImportFunc 0;
387+
} in
388+
(import, func_type)
389+
390+
(** Emit `env_count`/`arg_count`: call the appropriate `*_sizes_get`
391+
import (which writes count + buf_size into two i32 scratch slots),
392+
drop errno, return the count as i32. Uniform helper for the two
393+
builtins — they differ only in the import index. *)
394+
let gen_count_via_sizes_get
395+
(heap_ptr_global : int)
396+
(scratch_local : int)
397+
(sizes_func_idx : int)
398+
: instr list =
399+
[
400+
(* scratch = heap; heap += 8 (two i32 slots: count, buf_size) *)
401+
GlobalGet heap_ptr_global;
402+
I32Const 8l; I32Add;
403+
GlobalSet heap_ptr_global;
404+
GlobalGet heap_ptr_global;
405+
I32Const 8l; I32Sub;
406+
LocalSet scratch_local;
407+
(* sizes_get(count_ptr, bufsize_ptr); drop errno *)
408+
LocalGet scratch_local; (* count_ptr *)
409+
LocalGet scratch_local; I32Const 4l; I32Add; (* bufsize_ptr *)
410+
Call sizes_func_idx;
411+
Drop;
412+
(* return *count_ptr *)
413+
LocalGet scratch_local;
414+
I32Load (2, 0);
415+
]

tests/codegen/arg_count.affine

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// ADR-015 S4b (#180): arg_count() smoke. Lowers to
3+
// `wasi_snapshot_preview1.args_sizes_get`. Same shape as env_count;
4+
// proves the multi-WASI-import-on-use indexing handles two optional
5+
// imports simultaneously without collision.
6+
pub fn main() -> Int / { Time } {
7+
arg_count(())
8+
}

tests/codegen/env_count.affine

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// ADR-015 S4b (#180): env_count() smoke. Lowers to
3+
// `wasi_snapshot_preview1.environ_sizes_get`, returns the count
4+
// the host wrote into *count_ptr. Unit arg = zero-param-fn collapse
5+
// workaround (`fn() -> T` lowers to bare `T`; builtins use `Unit -> R`).
6+
pub fn main() -> Int / { Time } {
7+
env_count(())
8+
}

tests/codegen/test_arg_count.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// ADR-015 S4b (#180) — arg_count via WASI preview1 args_sizes_get.
3+
import assert from 'node:assert/strict';
4+
import { readFile } from 'node:fs/promises';
5+
6+
const buf = await readFile('./tests/codegen/arg_count.wasm');
7+
let inst = null;
8+
let observed = null;
9+
10+
const imports = {
11+
wasi_snapshot_preview1: {
12+
fd_write: () => 0,
13+
args_sizes_get: (argc_ptr, argv_buf_ptr) => {
14+
observed = { argc_ptr, argv_buf_ptr };
15+
const dv = new DataView(inst.exports.memory.buffer);
16+
dv.setUint32(argc_ptr, 3, true);
17+
dv.setUint32(argv_buf_ptr, 32, true);
18+
return 0;
19+
},
20+
},
21+
};
22+
23+
inst = (await WebAssembly.instantiate(buf, imports)).instance;
24+
const result = inst.exports.main();
25+
26+
assert.ok(observed, 'guest called args_sizes_get');
27+
assert.equal(result, 3, 'arg_count returns the count');
28+
console.log('test_arg_count.mjs OK');

tests/codegen/test_env_count.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// ADR-015 S4b (#180) — env_count via WASI preview1 environ_sizes_get.
3+
// Host stubs the import with a known count + buf_size; asserts the
4+
// guest returns that count.
5+
import assert from 'node:assert/strict';
6+
import { readFile } from 'node:fs/promises';
7+
8+
const buf = await readFile('./tests/codegen/env_count.wasm');
9+
let inst = null;
10+
let observed = null;
11+
12+
const imports = {
13+
wasi_snapshot_preview1: {
14+
fd_write: () => 0,
15+
environ_sizes_get: (envc_ptr, envbuf_ptr) => {
16+
observed = { envc_ptr, envbuf_ptr };
17+
const dv = new DataView(inst.exports.memory.buffer);
18+
dv.setUint32(envc_ptr, 7, true); // 7 env vars
19+
dv.setUint32(envbuf_ptr, 256, true); // unused by env_count
20+
return 0;
21+
},
22+
},
23+
};
24+
25+
inst = (await WebAssembly.instantiate(buf, imports)).instance;
26+
const result = inst.exports.main();
27+
28+
assert.ok(observed, 'guest called environ_sizes_get');
29+
assert.equal(result, 7, 'env_count returns the count the host wrote');
30+
console.log('test_env_count.mjs OK');

0 commit comments

Comments
 (0)