Merge branch 'wasm32' into dev-loop
This commit is contained in:
commit
32e20f03da
71
NEXT.md
71
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 `<stdio.h>`; 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.
|
||||
|
||||
58
bin/main.ml
58
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 <file.flan> [-o out] [--no-bounds-checks] [--dev]";
|
||||
"usage: flan build <file.flan> [-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) <file.flan>...\n\
|
||||
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev]\n\
|
||||
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] \
|
||||
[--target=wasm32-wasi]\n\
|
||||
\ flan run <file.flan> [args...]\n\
|
||||
\ flan reload <program.flan> <forms.flan> [-o out.so]\n\
|
||||
\ flan dev <program.flan> [-s socket]";
|
||||
|
||||
223
lib/build.ml
223
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 <stdio.h>.
|
||||
|
||||
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 \
|
||||
<resource-dir>/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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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;
|
||||
|
||||
23
test/wasm-run.mjs
Normal file
23
test/wasm-run.mjs
Normal file
@ -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);
|
||||
Loading…
x
Reference in New Issue
Block a user