A target is more than a triple, so build.ml learns the rest

wasm32-wasi needs a sysroot clang does not know about and a builtins archive
Fedora does not ship, and both have to reach the C compiles as well as the
link — flan_rt.c includes <stdio.h> and never got past it. The flags are
computed once and the whole list, not just the triple, is in the object cache
key: repointing a sysroot must not be served a stale .o.

Fedora ships no wasm libclang_rt.builtins.a and clang's resource directory is
root-owned, so a shadow one is built under the object cache with the archive
under the name clang looks for. The archive substituted is emscripten's
libcompiler_rt.a, a different triple built by a different clang; wasi-sdk is
the proper article and the comment says so, because a session reading "wasm32
works" should know which joint is glued. Nothing found means a refusal naming
every path tried.

The entry point is the other thing no triple tells you: wasi-libc calls
__main_argc_argv, the .ll says @main, and the mismatch links clean and then
traps on a weak stub. Two lines of C bridge it, and the asm label in them is
why the shim is not an infinite self-call.

--dev and Build.shared are refused for the target rather than half-supported:
both are dlopen, which wasm32 has no equivalent of.
This commit is contained in:
Joseph Ferano 2026-09-11 19:43:23 +07:00
parent 386d9e0372
commit 8e074bf0e2

View File

@ -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,159 @@ 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 () =
[ "/opt/wasi-sdk/lib/clang/20/lib/wasm32-unknown-wasi/libclang_rt.builtins.a" ]
@ (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 +243,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 +265,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 +276,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 +301,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
@ -186,6 +358,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