diff --git a/NEXT.md b/NEXT.md index 2f278d7..d22790f 100644 --- a/NEXT.md +++ b/NEXT.md @@ -161,6 +161,7 @@ reader ✅ → parse ✅ → load ✅ → check ✅ → emit ✅ → clang ✅ | `test/programs/restarts.flan` | **`restart-case` and `invoke-restart`: the transfer, across two frames** | | `test/test_emacs.ml` | **the client, driven against a real daemon and a real program** | | `test/reload_host.c` | the C host that loads and installs two rebuilds, in one process | +| `test/wasm-run.mjs` | **a WASI host in twenty lines of `node:wasi`, so the table can run a wasm32 build** | ``` $ flan run calc-me.flan "1 + 2 * (3 - 0.5) / 2" @@ -1496,19 +1497,63 @@ painful to retrofit: Deferred until after the dev loop: -6. **wasm32.** The user installed `wasi-libc-devel` and `wasi-libc-static`; the - sysroot is `/usr/wasm32-wasi` and `wasm-ld` is present. `clang - --target=wasm32-wasi --sysroot=/usr/wasm32-wasi` gets past the headers and - then **fails to link**: it wants - `lib/clang/20/lib/wasm32-unknown-wasi/libclang_rt.builtins.a`, which no - Fedora package provides (`dnf provides '*libclang_rt.builtins*wasm*'` finds - nothing). It has to come from a wasi-sdk release, dropped into clang's - resource directory. After that: teach `build.ml` `--sysroot`, and run the - acceptance table — `sand-headless.flan` included, which is exactly why it - does not import raylib — on both targets in CI. - Note plan.org has the *web* build linking raylib via emscripten, which - brings its own sysroot: wasi-sdk is right for the headless table, not - necessarily for the eventual game build. +6. ~~**wasm32.**~~ **Done, with one glued joint.** `flan build + --target=wasm32-wasi` produces a module, and `test/programs/sand-headless.flan` + prints `2256461126764447066` under it — the same hash as native, byte for + byte, at `-O2` and at `-O0`. That is the milestone: the RNG is ours rather + than libc's precisely so that number can be compared across targets, and it + compares equal. `values.flan` and `machine.flan` run there too, which is + where a 32-bit pointer would have shown. The acceptance table runs all four, + and skips them by *probing* — it builds the smallest program and runs it — + rather than by looking for a binary on PATH. + + Three things this cost that were not in the old note: + + - **The entry point is not `main`.** wasi-libc's start code calls + `__main_argc_argv`; clang renames C's argc/argv `main` to that, and the + `.ll` `Emit` writes says `@main` literally. The link succeeds and the + program traps on a signature-mismatched weak stub. `Build.wasm_main_source` + is a two-line C shim that bridges it, and the `__asm__("main")` label in it + is load-bearing: spelling the callee `main` makes clang rename *that* too + and the shim becomes an infinite self-call. + - **The target has to reach the C compiles, not just the link.** `flan_rt.c` + includes ``; without `--sysroot` it never gets that far. + `target_flags` is computed once and passed to both, and the whole flag + list — not just the triple — is in the object cache key, so repointing a + sysroot cannot serve a stale `.o`. + - **Fedora's sysroot is one level deeper** than wasi-sdk's: + `include/wasm32-wasi/stdio.h`, not `include/stdio.h`. Both shapes count. + + **The glued joint, and the one thing this contradicts in the old note.** The + old note said the builtins archive has to come from a wasi-sdk release. It + does not have to: emscripten builds the same compiler-rt for wasm32 and + calls it `libcompiler_rt.a`, and dropping that in as + `libclang_rt.builtins.a` links and runs. It is a different triple + (`wasm32-unknown-emscripten`) built by a different clang (22 against + Fedora's 20), so it is *substituting*, and wasi-sdk is still the proper + article. `build.ml` looks for `FLAN_WASM_BUILTINS`, then + `/opt/wasi-sdk/...`, then emscripten's beside `emcc` on PATH, and refuses by + name listing every path it tried when none is there. clang's resource + directory is root-owned, so the archive is not dropped into it — a shadow + resource directory is built under the object cache, named by a digest of + clang's own resource dir plus the archive's path, size and mtime, with the + real `include` symlinked in. + + **The runtime is Node.** No `wasmtime` and no `wasmer` on this machine; + `test/wasm-run.mjs` is twenty lines of `node:wasi` and the table prefers + `wasmtime` or `wasmer` if either appears. `--no-warnings`, because + `node:wasi` writes an `ExperimentalWarning` to stderr on every run and the + harness compares combined output. + + **Refused by name, not half-supported:** `--dev` with a wasm target (the + reload path is `dlopen`), `Build.shared` with one (same reason), and `flan + run --target=` (a `.wasm` is not something this host execs — build it and + point a runtime at it). + + Still open: raylib on wasm, which plan.org wants through emscripten and its + own sysroot. wasi-sdk is right for the headless table; it is not necessarily + right for the eventual game build. + 7. **Loose ends from milestone 4**, none of them blocking: block-scoped `defer`; package visibility, so `rl/get-color-raw` is not callable; a package importing a package; imported unions. diff --git a/bin/main.ml b/bin/main.ml index 6c35efa..07cc208 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -46,6 +46,24 @@ let dev_flag = "--dev" let flags = [ no_checks_flag; dev_flag ] +(* [--target=wasm32-wasi], the one cross target. Unlike the flags above it + carries a value, so it is matched by prefix and stripped from the residual + arguments by the same test — otherwise [-o out --target=X] falls into the + usage error. *) +let target_prefix = "--target=" + +let is_flag a = + List.mem a flags || String.starts_with ~prefix:target_prefix a + +let target_of args = + List.find_map + (fun a -> + if String.starts_with ~prefix:target_prefix a then + Some (String.sub a (String.length target_prefix) + (String.length a - String.length target_prefix)) + else None) + args + let () = match Array.to_list Sys.argv with | _ :: "read" :: files when files <> [] -> @@ -82,10 +100,19 @@ let () = (Flan.Types.to_string f.ret) (Array.length f.slots)) p.fns)) files - | _ :: "emit" :: args when List.exists (fun a -> not (List.mem a flags)) args -> + (* The IR is target-independent — [Emit] writes no triple and no datalayout, + which is what lets one .ll serve both targets — so there is nothing for a + target to change here. Refused rather than accepted and ignored: silently + swallowing a flag is the shape the house rule exists to prevent. *) + | _ :: "emit" :: args when target_of args <> None -> + prerr_endline + "flan emit: --target is refused — the emitted IR carries no triple and \ + no datalayout, and the target is chosen at build."; + exit 2 + | _ :: "emit" :: args when List.exists (fun a -> not (is_flag a)) args -> let checks = not (List.mem no_checks_flag args) in let dev = List.mem dev_flag args in - let files = List.filter (fun a -> not (List.mem a flags)) args in + let files = List.filter (fun a -> not (is_flag a)) args in List.iter (fun path -> with_errors path (fun () -> @@ -94,20 +121,28 @@ let () = | _ :: "build" :: path :: rest -> let checks = not (List.mem no_checks_flag rest) in let dev = List.mem dev_flag rest in + let target = target_of rest in let out = - match List.filter (fun a -> not (List.mem a flags)) rest with + match List.filter (fun a -> not (is_flag a)) rest with | [ "-o"; o ] -> o - | [] -> Filename.remove_extension (Filename.basename path) + | [] -> + let base = Filename.remove_extension (Filename.basename path) in + (* A wasm module is not an executable and must not be named like one: + the extension is what tells a runtime, and a reader, what it is. *) + (match target with + | Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm" + | _ -> base) | _ -> prerr_endline - "usage: flan build [-o out] [--no-bounds-checks] [--dev]"; + "usage: flan build [-o out] [--no-bounds-checks] \ + [--dev] [--target=wasm32-wasi]"; exit 2 in with_errors path (fun () -> let l = load path in let p = Flan.Check.program l.decls in ignore (Flan.Build.executable - ~opts:{ Flan.Build.default with checks; dev } + ~opts:{ Flan.Build.default with checks; dev; target } ~csrcs:l.csrcs ~lflags:l.lflags p ~out)) (* The daemon an editor talks to: one session, the program it belongs to running beside it, and a socket. Unlike [flan reload] the session persists, @@ -146,6 +181,14 @@ let () = Printf.eprintf "%s %s llc %.1fms ld %.1fms\n" out (String.concat " " c.Flan.Session.fns) timing.Flan.Build.llc_ms timing.Flan.Build.link_ms) + (* [run] builds and execs. A .wasm is not executable, and picking a runtime + for it is a decision this command has no business making, so a cross + target is refused here by name rather than half-supported. *) + | _ :: "run" :: _ :: args when target_of args <> None -> + prerr_endline + "flan run: --target is refused — a cross-built module is not something \ + this host can exec. Use flan build --target=... and a wasm runtime."; + exit 2 | _ :: "run" :: path :: args -> with_errors path (fun () -> let exe = @@ -163,7 +206,8 @@ let () = | _ -> prerr_endline "usage: flan (read|parse|check|emit) ...\n\ - \ flan build [-o out] [--no-bounds-checks] [--dev]\n\ + \ flan build [-o out] [--no-bounds-checks] [--dev] \ + [--target=wasm32-wasi]\n\ \ flan run [args...]\n\ \ flan reload [-o out.so]\n\ \ flan dev [-s socket]"; diff --git a/lib/build.ml b/lib/build.ml index 3a10f6e..7733b88 100644 --- a/lib/build.ml +++ b/lib/build.ml @@ -15,6 +15,21 @@ let write path contents = output_string ch contents; close_out ch +let write_bin path contents = + let ch = open_out_bin path in + output_string ch contents; + close_out ch + +let read_file path = + let ch = open_in_bin path in + let n = in_channel_length ch in + let s = really_input_string ch n in + close_in ch; + s + +let read_file_opt path = + try Some (read_file path) with Sys_error _ -> None + (* One temporary directory per build, so the .ll is findable by name when something is wrong with it. *) let workdir () = @@ -53,6 +68,170 @@ type opts = { let default = { target = None; opt = "-O2"; keep = false; checks = true; dev = false } +(* ── wasm32, which needs more than a triple ────────────────────────── + The native target is whatever clang was built for, so [--target=] alone is + the whole of it. wasm32-wasi is not: the headers come from a sysroot clang + does not know about, and the builtins archive is not in clang's resource + directory on Fedora at all. Both have to be found, and *both* have to reach + the C compiles as well as the link — [flan_rt.c] includes . + + Anything missing is refused by name, with the path that is missing and the + package that would supply it. A build that reports success for a target it + cannot actually produce is the one outcome worth avoiding here. *) + +let getenv name = try Some (Sys.getenv name) with Not_found -> None + +let is_wasm t = String.starts_with ~prefix:"wasm32" t + +let wasm_target opts = + match opts.target with Some t when is_wasm t -> true | _ -> false + +(* Where on PATH a program is, or None. *) +let on_path prog = + let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in + List.find_map + (fun d -> + let p = Filename.concat d prog in + if Sys.file_exists p then Some p else None) + dirs + +let wasm_sysroot () = + match getenv "FLAN_WASM_SYSROOT" with Some s -> s | None -> "/usr/wasm32-wasi" + +(* The builtins archive — __muldi3, the float conversions, memcpy. Fedora's + clang ships no wasm copy of it (dnf provides '*libclang_rt.builtins*wasm*' + finds nothing) and the proper article comes from a wasi-sdk release. + Failing that, emscripten builds the same compiler-rt for wasm32 and calls it + libcompiler_rt.a; it is a different triple (wasm32-unknown-emscripten) built + by a different clang, and it is *substituting* here, not the real thing. It + links and runs, and the sand hash matches native byte for byte, but a + session reading this should know the joint is glued. *) +let wasm_builtins_candidates () = + (* wasi-sdk's own resource directory, whichever LLVM that release bundled — + the version is in the path and moves release to release, so it is read + rather than guessed. Same rule as [clang_resource_dir]. *) + (let root = "/opt/wasi-sdk/lib/clang" in + match Sys.readdir root with + | vs -> + Array.sort compare vs; + Array.to_list vs + |> List.map (fun v -> + Filename.concat root + (Filename.concat v "lib/wasm32-unknown-wasi/libclang_rt.builtins.a")) + | exception Sys_error _ -> []) + @ (match on_path "emcc" with + | None -> [] + | Some e -> + [ Filename.concat (Filename.dirname e) + "cache/sysroot/lib/wasm32-emscripten/libcompiler_rt.a" ]) + +let wasm_builtins () = + (* An explicit FLAN_WASM_BUILTINS that does not exist is an error and not a + hint: falling back to a guess would build against something other than + what was asked for and say nothing. *) + match getenv "FLAN_WASM_BUILTINS" with + | Some s when Sys.file_exists s -> s + | Some s -> + failwith (Printf.sprintf "wasm32: FLAN_WASM_BUILTINS is %s, which does not exist" s) + | None -> + let cands = wasm_builtins_candidates () in + match List.find_opt Sys.file_exists cands with + | Some p -> p + | None -> + failwith + (Printf.sprintf + "wasm32: no builtins archive. clang wants \ + /lib/wasm32-unknown-wasi/libclang_rt.builtins.a, which \ + no Fedora package provides; it comes from a wasi-sdk release, or \ + emscripten's libcompiler_rt.a will substitute. Looked in: %s. Set \ + FLAN_WASM_BUILTINS to the archive." + (String.concat ", " cands)) + +(* clang's own resource directory, asked for rather than guessed — the version + number is in the path and a Fedora clang bump changes it. *) +let clang_resource_dir = + lazy + (let tmp = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "flan-rd-%d" (Unix.getpid ())) + in + let code = + Sys.command + (Printf.sprintf "%s -print-resource-dir > %s 2>/dev/null" + (Filename.quote clang) (Filename.quote tmp)) + in + let s = if code = 0 then read_file_opt tmp else None in + (try Sys.remove tmp with Sys_error _ -> ()); + match s with + | Some s -> String.trim s + | None -> failwith "wasm32: clang -print-resource-dir failed") + +(* A resource directory clang will accept for wasm32-wasi: its real include + directory, and the builtins archive under the name and triple clang looks + for. Built under the object cache and named by a digest of what went into + it, so repointing FLAN_WASM_BUILTINS or upgrading clang makes a new one + rather than reusing a stale one. *) +let wasm_resource_dir () = + let real = Lazy.force clang_resource_dir in + let builtins = wasm_builtins () in + let st = Unix.stat builtins in + let key = + Digest.to_hex + (Digest.string + (String.concat "\000" + [ real; builtins; string_of_int st.Unix.st_size; + string_of_float st.Unix.st_mtime ])) + in + let dir = Filename.concat (cachedir ()) ("wasm-rd-" ^ key) in + let lib = Filename.concat dir "lib" in + let triple = Filename.concat lib "wasm32-unknown-wasi" in + let archive = Filename.concat triple "libclang_rt.builtins.a" in + if not (Sys.file_exists archive) then begin + let mk d = try Unix.mkdir d 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> () in + mk dir; mk lib; mk triple; + let inc = Filename.concat dir "include" in + if not (Sys.file_exists inc) then + (try Unix.symlink (Filename.concat real "include") inc + with Unix.Unix_error _ -> ()); + let tmp = Printf.sprintf "%s.%d.tmp" archive (Unix.getpid ()) in + write_bin tmp (read_file builtins); + (try Unix.rename tmp archive with Unix.Unix_error _ -> ()) + end; + dir + +(* The flags a target adds, used by the C compiles and by the link alike. *) +let target_flags opts = + match opts.target with + | None -> [] + | Some t when not (is_wasm t) -> [ "--target=" ^ t ] + | Some t -> + let sysroot = wasm_sysroot () in + (* Fedora's wasi-libc puts the headers one level deeper than wasi-sdk's + does — include/wasm32-wasi/stdio.h against include/stdio.h — so both + shapes count as a sysroot. clang finds either on its own. *) + if not (List.exists + (fun p -> Sys.file_exists (Filename.concat sysroot p)) + [ "include/stdio.h"; "include/wasm32-wasi/stdio.h" ]) + then + failwith + (Printf.sprintf + "wasm32: no sysroot at %s (wanted include/stdio.h). Install \ + wasi-libc-devel and wasi-libc-static, or set FLAN_WASM_SYSROOT." + sysroot); + [ "--target=" ^ t; "--sysroot=" ^ sysroot; + "-resource-dir=" ^ wasm_resource_dir () ] + +(* wasi-libc's start code calls __main_argc_argv, not main: clang *renames* + C's argc/argv [main] to that when it compiles C for wasm32, and the .ll + Emit writes says @main literally. Without this the link succeeds and the + program traps at its first instruction on a signature-mismatched weak stub. + The __asm__ label is load-bearing and must not be "simplified" away — + spelling the callee [main] makes clang rename *that* too, and the shim + becomes an infinite self-call that hangs rather than failing. *) +let wasm_main_source = + "int flan_entry(int argc, char **argv) __asm__(\"main\");\n\ + int __main_argc_argv(int argc, char **argv) { return flan_entry(argc, argv); }\n" + (* What the compiler itself is, cheaply: its path, size and mtime. A clang upgrade changes one of those, so the key changes with it — without paying a [clang --version] subprocess on every build, which would cost most of what @@ -75,13 +254,17 @@ let clang_stamp = the source text, the compiler and the flags are all unchanged. The key has to carry [opt] and [target]: the acceptance table builds the same programs at -O0 and -O2, and an -O2 object must not serve an -O0 build. *) -let compile_c ~opts ~src ~name = +let compile_c ~opts ?tflags ~src ~name () = + (* The whole flag list, not just the triple: on wasm32 the sysroot and the + resource directory decide which headers and which builtins an object was + built against, so repointing either must not serve a stale .o. *) + let tflags = match tflags with Some f -> f | None -> target_flags opts in let key = Digest.to_hex (Digest.string (String.concat "\000" [ name; src; Lazy.force clang_stamp; opts.opt; - (match opts.target with None -> "" | Some t -> t) ])) + String.concat " " tflags ])) in let obj = Filename.concat (cachedir ()) (key ^ ".o") in if not (Sys.file_exists obj) then begin @@ -93,8 +276,7 @@ let compile_c ~opts ~src ~name = let tmp = Printf.sprintf "%s.%d.tmp" obj (Unix.getpid ()) in let cmd = String.concat " " - ([ Filename.quote clang; opts.opt; "-c" ] - @ (match opts.target with None -> [] | Some t -> [ "--target=" ^ t ]) + ([ Filename.quote clang; opts.opt; "-c" ] @ tflags @ [ Filename.quote c; "-o"; Filename.quote tmp ]) in let code = Sys.command cmd in @@ -105,18 +287,19 @@ let compile_c ~opts ~src ~name = end; obj -let read_file path = - let ch = open_in_bin path in - let n = in_channel_length ch in - let s = really_input_string ch n in - close_in ch; - s - (* [csrcs] and [lflags] come from the imported packages (see [Load]): the C shim a package binds through, and the arguments needed to link the library it binds to. *) let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) (p : Tast.program) ~out = + (* A dev build is the REPL's, and the REPL reaches a running process through + [-rdynamic] and [dlopen]. Neither exists on wasm32, so the combination is + refused rather than quietly producing a module nothing can attach to. *) + if wasm_target opts && opts.dev then + failwith + "wasm32: --dev is native only — the reload path is dlopen, which wasm32 \ + has no equivalent of"; + let tflags = target_flags opts in let dir = workdir () in let ll = Filename.concat dir (Filename.basename out ^ ".ll") in write ll (Emit.program ~checks:opts.checks ~dev:opts.dev p); @@ -129,19 +312,19 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) rather than as a missing flag. The table is BSS, so this costs address space and not binary size, and [-rdynamic] and the cells are still what [--dev] means. *) + let cc src name = compile_c ~opts ~tflags ~src ~name () in let objs = - compile_c ~opts ~src:Runtime_src.source ~name:"flan_rt.c" - :: [ compile_c ~opts ~src:Runtime_src.dev_source ~name:"flan_dev.c" ] - @ List.map - (fun c -> - compile_c ~opts ~src:(read_file c) ~name:(Filename.basename c)) - csrcs + cc Runtime_src.source "flan_rt.c" + :: [ cc Runtime_src.dev_source "flan_dev.c" ] + (* wasi-libc's entry point, which is not [main]. See [wasm_main_source]. *) + @ (if wasm_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else []) + @ List.map (fun c -> cc (read_file c) (Filename.basename c)) csrcs in let cmd = String.concat " " ([ Filename.quote clang; opts.opt; "-Wno-override-module" ] @ (if opts.dev then [ "-rdynamic" ] else []) - @ (match opts.target with None -> [] | Some t -> [ "--target=" ^ t ]) + @ tflags @ [ Filename.quote ll ] @ List.map Filename.quote objs @ lflags @@ -195,6 +378,10 @@ let run what cmd = if code <> 0 then failwith (Printf.sprintf "%s failed (exit %d)" what code) let shared ?(opts = default) ~ir ~out () : timing = + if wasm_target opts then + failwith + "wasm32: the reload path is native only — it is llc + ld -shared + \ + dlopen, and wasm32 has no dlopen"; let dir = workdir () in let base = Filename.remove_extension (Filename.basename out) in let ll = Filename.concat dir (base ^ ".ll") in diff --git a/test/dune b/test/dune index 6830b44..738c770 100644 --- a/test/dune +++ b/test/dune @@ -18,4 +18,7 @@ ; test_dev runs the compiler itself: flan dev launches and owns a program. (file %{workspace_root}/bin/main.exe) ; The Emacs client, which test_emacs drives against a real daemon. - (glob_files %{workspace_root}/emacs/*.el))) + (glob_files %{workspace_root}/emacs/*.el) + ; The WASI host the wasm32 case runs its module under, when no wasmtime or + ; wasmer is installed. + (file wasm-run.mjs))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 068ff1a..300a7e7 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -437,6 +437,111 @@ let () = print_endline "FAIL --no-bounds-checks: a check survived" end; + (* ── wasm32 (NEXT.md, deferred item 6) ────────────────────────────── + The second target, and the reason sand-headless imports no raylib. What + is asserted is not that a wasm module exists — it is that it prints the + *same hash* as the native build, byte for byte. That is only possible + because rand-f32 is written in Flan rather than bound to libc, so the + case is the regression test for that decision as much as for the port. + + Four independent things can be absent — clang's wasm target, the + wasi-libc sysroot, a builtins archive, and a runtime that speaks WASI — + so the skip is a *probe*: build the smallest program and run it. A + [which] would go red on the machine where Node is too old, with a + reason nobody could read. *) + let wasm_runner = + if Sys.command "command -v wasmtime > /dev/null 2>&1" = 0 then + Some "wasmtime" + else if Sys.command "command -v wasmer > /dev/null 2>&1" = 0 then + Some "wasmer run" + else if Sys.command "command -v node > /dev/null 2>&1" = 0 then + (* --no-warnings because node:wasi prints an ExperimentalWarning to + stderr on every run, and this harness compares combined output. *) + Some "node --no-warnings wasm-run.mjs" + else None + in + let wasm_build ?(opt = "-O2") path out = + let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in + let p = Check.program l.Load.decls in + ignore + (Build.executable + ~opts:{ Build.default with opt; target = Some "wasm32-wasi" } + ~csrcs:l.Load.csrcs ~lflags:l.Load.lflags p ~out) + in + let wasm_run ?arg runner wasm = + let out = Filename.concat scratch "flan-acceptance-wasm.out" in + let code = + Sys.command + (Printf.sprintf "%s %s %s > %s 2>&1" runner (Filename.quote wasm) + (match arg with None -> "" | Some a -> Filename.quote a) + (Filename.quote out)) + in + let text = In_channel.with_open_bin out In_channel.input_all in + (try Sys.remove out with Sys_error _ -> ()); + (code, text) + in + (match wasm_runner with + | None -> + print_endline + "acceptance: skipping the wasm32 case (no wasmtime, wasmer or node)" + | Some runner -> + let probe = Filename.concat scratch "flan-wasm-probe.wasm" in + let outcome = + match wasm_build "programs/unit-main.flan" probe with + | () -> + let code, text = wasm_run runner probe in + if code = 0 && text = "ok\n" then Ok () + else + Error + (Printf.sprintf "%s could not run it: %S (exit %d)" runner text + code) + | exception Failure m -> Error m + in + (try Sys.remove probe with Sys_error _ -> ()); + (match outcome with + | Error why -> + Printf.printf "acceptance: skipping the wasm32 case (%s)\n" why + | Ok () -> + let wasm_case name ?opt ?arg path expected = + let wasm = + Filename.concat scratch + ("flan-w-" ^ Filename.remove_extension (Filename.basename path) + ^ ".wasm") + in + wasm_build ?opt path wasm; + let code, text = wasm_run ?arg runner wasm in + if text <> expected || code <> 0 then begin + incr failures; + Printf.printf + "FAIL %s\n got: %S (exit %d)\n wanted: %S (exit 0)\n" + name text code expected + end; + (try Sys.remove wasm with Sys_error _ -> ()) + in + (* The hash, which must equal the native one above. At both levels: + agreement at -O2 alone could be a coincidence of how LLVM folded + the float arithmetic, and -O0 is the cheap way to say it is not. *) + wasm_case "sand, headless, wasm32" "programs/sand-headless.flan" + sand_out; + wasm_case "sand, headless, wasm32, -O0" ~opt:"-O0" + "programs/sand-headless.flan" sand_out; + (* And the two fixed-output programs, which between them cover the + milestone-2 surface: globals, 2-D arrays, places through a + pointer, casts and match. A 32-bit pointer is the thing most + likely to go wrong and these are where it would show. *) + wasm_case "value semantics, wasm32" "programs/values.flan" values_out; + wasm_case "machine surface, wasm32" "programs/machine.flan" + machine_out; + (* calc-me, for the one host-ABI path the three above do not touch: + [flan_argv] builds an array of flan_slice in C and Flan indexes it + as [string], so what is pinned here is the element *stride* of a + ptr+len pair, which is 16 bytes native and 12 on wasm32 — not a + field offset, and nothing else in the table reaches it. This is + also the file header's own claim, that the table runs on wasm32 + too, honoured for the first time. *) + wasm_case "calc-me, wasm32" "../calc-me.flan" + ~arg:"1 + 2 * (3 - 0.5) / 2" "3.5\n")); + if !failures = 0 then print_endline "acceptance: all tests passed" else begin Printf.printf "\n%d failure(s)\n" !failures; diff --git a/test/wasm-run.mjs b/test/wasm-run.mjs new file mode 100644 index 0000000..5d4d5d4 --- /dev/null +++ b/test/wasm-run.mjs @@ -0,0 +1,23 @@ +// A WASI host, so the acceptance table can run a wasm32 build without a +// wasmtime or wasmer being installed. Node has had node:wasi since 16; it is +// still flagged experimental, hence --no-warnings at the call site, and before +// Node 20 it needs --experimental-wasi-unstable-preview1 as well. The +// acceptance test probes rather than assumes: if this file cannot run the +// module, the wasm case is skipped with what went wrong. +// +// preopens is empty on purpose. sand-headless reads nothing and writes one +// line to stdout, which is the whole point of it being the cross-target case. +import { WASI } from 'node:wasi'; +import { readFile } from 'node:fs/promises'; + +const [, , file, ...rest] = process.argv; +const wasi = new WASI({ + version: 'preview1', + args: [file, ...rest], + env: {}, + preopens: {}, + returnOnExit: true, +}); +const mod = await WebAssembly.compile(await readFile(file)); +const inst = await WebAssembly.instantiate(mod, wasi.getImportObject()); +process.exitCode = wasi.start(inst);