From 8e074bf0e23909c6f9d2e828d25a3e13aa05552e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 19:43:23 +0700 Subject: [PATCH] A target is more than a triple, so build.ml learns the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- lib/build.ml | 212 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 194 insertions(+), 18 deletions(-) diff --git a/lib/build.ml b/lib/build.ml index a1dfa8d..37f5ea1 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,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 . + + 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 \ + /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