diff --git a/bin/main.ml b/bin/main.ml index 8cd61ec..35d178f 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -81,7 +81,8 @@ let sanitize_flag = "--sanitize" let flags = [ no_checks_flag; dev_flag; debug_flag; sanitize_flag ] -(* [--target=wasm32-wasi], the one cross target. Unlike the flags above it +(* [--target=wasm32-wasi] and [--target=web], the two cross targets. Unlike + the flags above, a target 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. *) @@ -187,13 +188,17 @@ let () = 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. *) + (* A web build is three files — the page, its JS and the module — and + the page is the one named here: emcc derives the other two from it, + and it is the one a browser opens. *) (match target with + | Some t when Flan.Build.is_web t -> base ^ ".html" | Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm" | _ -> base) | _ -> prerr_endline "usage: flan build [-o out] [--no-bounds-checks] \ - [--dev] [--debug] [--sanitize] [--target=wasm32-wasi]"; + [--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web]"; exit 2 in with_errors path (fun () -> @@ -285,7 +290,7 @@ let () = prerr_endline "usage: flan (read|parse|check|emit|shim) ...\n\ \ flan build [-o out] [--no-bounds-checks] [--dev] \ - [--debug] [--sanitize] [--target=wasm32-wasi]\n\ + [--debug] [--sanitize] [--target=wasm32-wasi|web]\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 4886c31..af575e3 100644 --- a/lib/build.ml +++ b/lib/build.ml @@ -4,12 +4,24 @@ {v flan → typed IR → .ll → clang --target={native,wasm32} v} + Three targets now, and the third takes a different driver: the browser is + built by emcc, which accepts the same .ll. See [is_web]. + Not the dev path — that one never invokes the clang driver, because the driver *is* the cost, and goes llc + ld -shared + dlopen instead. That is [shared], at the bottom of this file, measured at ~19ms. *) let clang = try Sys.getenv "FLAN_CLANG" with Not_found -> "clang" +(* The browser target's compiler. emcc is a clang driver with a sysroot, a + builtins archive, a JS runtime and an HTML shell already attached, so the + whole of [wasm_sysroot], [wasm_builtins] and [wasm_resource_dir] below — + everything the wasi target has to find by hand — is what emcc *is*. It also + accepts a .ll on its command line, which is the one thing that had to be + true for this target to exist at all: [Emit] writes IR text and nothing + else. *) +let emcc = try Sys.getenv "FLAN_EMCC" with Not_found -> "emcc" + let write path contents = let ch = open_out path in output_string ch contents; @@ -50,7 +62,10 @@ let cachedir () = d type opts = { - target : string option; (* None is the host; "wasm32-wasi" is the other *) + (* None is the host. "wasm32-wasi" is the headless one, run by a WASI + runtime; "web" is the browser one, built by emscripten. They share the + word wasm and almost nothing else — see [is_wasi] and [is_web]. *) + target : string option; opt : string; keep : bool; (* leave the .ll behind *) checks : bool; (* bounds-check [at] and [slice] *) @@ -134,11 +149,38 @@ let cflags opts = let getenv name = try Some (Sys.getenv name) with Not_found -> None -let is_wasm t = String.starts_with ~prefix:"wasm32" t +(* ── Two wasm targets, spelled apart ───────────────────────────────── + [web] is emscripten's: a browser, a GL context, a JS runtime, and a clang + whose sysroot and builtins come with it. [wasm32-wasi] is the headless one: + a WASI runtime, no GL, no browser, and a sysroot this file has to find. + "wasm32-unknown-emscripten" is accepted as a synonym for [web] because that + is the triple, and someone will write it. + + What the two share is the machine — 32-bit pointers, no dlopen — which is + exactly the set of things [is_wasm] guards: the three refusals below are + about the machine and so they apply to both. *) +let is_web t = + t = "web" || t = "emscripten" + || String.starts_with ~prefix:"wasm32-unknown-emscripten" t + || String.starts_with ~prefix:"wasm32-emscripten" t + +let is_wasi t = String.starts_with ~prefix:"wasm32" t && not (is_web t) +let is_wasm t = is_web t || is_wasi t let wasm_target opts = match opts.target with Some t when is_wasm t -> true | _ -> false +let web_target opts = + match opts.target with Some t when is_web t -> true | _ -> false + +let wasi_target opts = + match opts.target with Some t when is_wasi t -> true | _ -> false + +(* Which compiler a target is built by. This is not a flag difference: emcc is + a different program with a different driver, and the object cache key below + carries it for the same reason it carries clang's mtime. *) +let compiler opts = if web_target opts then emcc else clang + (* 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 @@ -256,6 +298,19 @@ let wasm_resource_dir () = let target_flags opts = match opts.target with | None -> [] + (* The browser target adds no flags to a compile at all: emcc already is the + triple, the sysroot and the builtins. What it does need is to exist, and + an emcc that is not there must be refused here — where the reason can name + it — and not at the first "command not found" from a subshell. *) + | Some t when is_web t -> + if on_path emcc = None && not (Sys.file_exists emcc) then + failwith + (Printf.sprintf + "web: no %s on PATH. The browser target is built by emscripten: \ + install an emsdk and source its emsdk_env.sh, or set FLAN_EMCC to \ + the emcc to use." + emcc); + [] | Some t when not (is_wasm t) -> [ "--target=" ^ t ] | Some t -> let sysroot = wasm_sysroot () in @@ -285,23 +340,202 @@ 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" +(* ── The browser: the main loop, the shell, and the link ───────────── + The mechanism, and why it is not the one the old note predicted. + + BUILT.md says a web build "drives the loop with [emscripten_set_main_loop] + instead of a [while]. That is a different [main], not a different program." + The first half is right about the browser and wrong about what it costs + here: [emscripten_set_main_loop] wants the loop body as a callback, so every + example that spells + + (until (rl/window-should-close?) ...) + + would have to be cut in half — an init and a tick — by hand, in every file, + and the resulting program would no longer be the one that runs natively. + + [-sASYNCIFY] is the other half of the same browser fact and costs none of + that. It rewrites the module so a call can suspend and resume across a + return to the event loop, and raylib's web platform is built for it: + [WindowShouldClose] on PLATFORM_WEB is an [emscripten_sleep(16)] that then + returns false (raylib 5.5, platforms/rcore_web.c). So the Flan [until] loop + yields to the browser once a frame, at the call it already makes, and not + one example changes. Same program, same [main], same source on both targets. + + What it costs is real and worth writing down: asyncify instruments the whole + module, which is roughly a doubling of code size and a measurable slowdown + on the instrumented paths. It is applied to every web build rather than to + the ones that block, because "does this program block" is not a question + this file can answer and a flag set that varies per program is a cache key + that varies per program. *) + +let web_shell_default = + {html| + + + + + flan + + + + +

+    
+    {{{ SCRIPT }}}
+  
+
+|html}
+
+(* The shell is a string here rather than a file in the tree for the same
+   reason [Runtime_src] is: it has to be present wherever the compiler is, and
+   a build that cannot find its own shell is a build that fails for a reason
+   nobody spelled. FLAN_WEB_SHELL replaces it. *)
+let web_shell_file () =
+  match getenv "FLAN_WEB_SHELL" with
+  | Some p when Sys.file_exists p -> p
+  | Some p ->
+    failwith (Printf.sprintf "web: FLAN_WEB_SHELL is %s, which does not exist" p)
+  | None ->
+    let p = Filename.concat (workdir ()) "flan-shell.html" in
+    write p web_shell_default;
+    p
+
+(* The flags the browser target adds at the link, and only at the link.
+
+   ALLOW_MEMORY_GROWTH because the default heap is 16MB and a texture is not
+   small. EXPORT_ES6=0 and the default MODULARIZE are left alone so that the
+   .js is a plain script both the shell and node can run — which is what makes
+   the test headless.
+
+   --shell-file only when the output is a page. Asking emcc for a .js and
+   handing it an HTML shell is accepted and ignored, which is the kind of
+   silence this file tries not to produce. *)
+let web_link_flags ~out =
+  [ "-sASYNCIFY"; "-sALLOW_MEMORY_GROWTH=1" ]
+  @ (if Filename.extension out = ".html" then
+       [ "--shell-file"; Filename.quote (web_shell_file ()) ]
+     else [])
+
+(* ── A [link] line may be addressed to one target ────────────────────
+   `vendor/raylib/link` names Fedora's libraylib.so.550, which exists on the
+   host and nowhere else; the browser wants a static archive built by
+   emscripten and three -s flags besides. So a line may carry a tag:
+
+       @native -l:libraylib.so.550
+       @web    ${FLAN_RAYLIB_WEB}
+
+   and an untagged line applies to every target, which is what every existing
+   `link` file is.
+
+   The selection happens *here* and not in [Load], which is where the file is
+   read, because [Load] resolves imports before a target is chosen — the same
+   program is built for both — and a package's linker arguments are carried to
+   this function as a flat list of strings. [Load] passing the lines through
+   untouched is the whole of its part in this.
+
+   ${NAME} expands from the environment. An unset one is refused by name: the
+   archive a web build needs is built once by vendor/raylib/build-web.sh and
+   lives at a path only that machine knows, and the alternative to naming it
+   here is a linker error about GLFW symbols. *)
+
+let link_tags = [ "native"; "wasi"; "web" ]
+
+let target_tag opts =
+  if web_target opts then "web"
+  else if wasi_target opts then "wasi"
+  else "native"
+
+(* ${NAME} → the environment's NAME. Nothing else is substituted: this is not a
+   shell, and a linker argument containing a $ that is not a ${ is left alone
+   rather than guessed at. *)
+let expand_vars ~where s =
+  let b = Buffer.create (String.length s) in
+  let n = String.length s in
+  let rec go i =
+    if i >= n then ()
+    else if i + 1 < n && s.[i] = '$' && s.[i + 1] = '{' then
+      match String.index_from_opt s (i + 2) '}' with
+      | None -> Buffer.add_char b s.[i]; go (i + 1)
+      | Some j ->
+        let name = String.sub s (i + 2) (j - i - 2) in
+        (match getenv name with
+         | Some v -> Buffer.add_string b v
+         | None ->
+           failwith
+             (Printf.sprintf
+                "%s: the linker argument %s wants %s, which is not set in the \
+                 environment"
+                where s name));
+        go (j + 1)
+    else (Buffer.add_char b s.[i]; go (i + 1))
+  in
+  go 0;
+  Buffer.contents b
+
+let select_lflags opts flags =
+  let want = target_tag opts in
+  List.filter_map
+    (fun f ->
+       if String.length f > 0 && f.[0] = '@' then begin
+         let tag, rest =
+           match String.index_opt f ' ' with
+           | Some i ->
+             (String.sub f 1 (i - 1),
+              String.trim (String.sub f (i + 1) (String.length f - i - 1)))
+           | None -> (String.sub f 1 (String.length f - 1), "")
+         in
+         if not (List.mem tag link_tags) then
+           failwith
+             (Printf.sprintf
+                "link: @%s is not a target — the tags are %s, and an untagged \
+                 line applies to all of them"
+                tag
+                (String.concat ", " (List.map (fun t -> "@" ^ t) link_tags)));
+         if tag = want && rest <> "" then
+           Some (expand_vars ~where:("link: @" ^ tag) rest)
+         else None
+       end
+       else Some (expand_vars ~where:"link" f))
+    flags
+
 (* 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
-   the cache buys. *)
-let clang_stamp =
-  lazy
-    (let path =
-       if Filename.is_relative clang then
-         let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
-         (try List.find (fun d -> Sys.file_exists (Filename.concat d clang))
-                dirs |> fun d -> Filename.concat d clang
-          with Not_found -> clang)
-       else clang
-     in
-     match Unix.stat path with
-     | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
-     | exception Unix.Unix_error _ -> path)
+   the cache buys. Taken of whichever compiler the target uses, so an emcc
+   object and a clang object of the same source cannot collide. *)
+let stamp_of prog =
+  let path =
+    if Filename.is_relative prog then
+      let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
+      (try List.find (fun d -> Sys.file_exists (Filename.concat d prog))
+             dirs |> fun d -> Filename.concat d prog
+       with Not_found -> prog)
+    else prog
+  in
+  match Unix.stat path with
+  | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
+  | exception Unix.Unix_error _ -> path
+
+let clang_stamp = lazy (stamp_of clang)
 
 (* Compile one C translation unit to an object file, reusing a cached one when
    the source text, the compiler and the flags are all unchanged. The key has
@@ -312,11 +546,12 @@ let compile_c ~opts ?tflags ~src ~name () =
      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 cc = compiler opts in
   let key =
     Digest.to_hex
       (Digest.string
          (String.concat "\000"
-            [ name; src; Lazy.force clang_stamp; opts.opt;
+            [ name; src; stamp_of cc; opts.opt;
               String.concat " " (cflags opts);
               String.concat " " tflags ]))
   in
@@ -330,14 +565,14 @@ let compile_c ~opts ?tflags ~src ~name () =
     let tmp = Printf.sprintf "%s.%d.tmp" obj (Unix.getpid ()) in
     let cmd =
       String.concat " "
-        ([ Filename.quote clang; opts.opt ]
+        ([ Filename.quote cc; opts.opt ]
          @ cflags opts
          @ [ "-c" ] @ tflags
          @ [ Filename.quote c; "-o"; Filename.quote tmp ])
     in
     let code = Sys.command cmd in
     if code <> 0 then
-      failwith (Printf.sprintf "%s failed (exit %d) on %s" clang code name);
+      failwith (Printf.sprintf "%s failed (exit %d) on %s" cc code name);
     (try Unix.rename tmp obj with Unix.Unix_error _ -> ());
     (try Sys.remove c with Sys_error _ -> ())
   end;
@@ -351,10 +586,15 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   (* 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. *)
+  (* Named for the target that was asked for, since two of them answer to
+     [wasm_target] now and "wasm32:" on a --target=web build reads as a
+     compiler that did not hear the question. *)
+  let tname = if web_target opts then "web" else "wasm32" in
   if wasm_target opts && opts.dev then
     failwith
-      "wasm32: --dev is native only — the reload path is dlopen, which wasm32 \
-       has no equivalent of";
+      (tname
+       ^ ": --dev is native only — the reload path is dlopen, which wasm has \
+          no equivalent of");
   (* Refused rather than emitted-and-hoped-for. The member offsets in the DWARF
      are computed for the host's layout — [ptr] 8 bytes — and wasm32's pointer
      is 4, so a slice's [len] is at byte 8 there and at byte 16 here. Emitting
@@ -363,16 +603,26 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
      keeps meeting at the FFI boundary. *)
   if wasm_target opts && opts.debug then
     failwith
-      "wasm32: --debug is native only — the DWARF member offsets are computed \
-       for the host's layout, and wasm32's 32-bit pointer moves every one of \
-       them";
+      (tname
+       ^ ": --debug is native only — the DWARF member offsets are computed \
+          for the host's layout, and wasm32's 32-bit pointer moves every one \
+          of them");
   (* There is no wasm32 sanitizer runtime to link against: clang accepts
      -fsanitize=address for the triple and the link fails on
      __asan_report_load4. Refused by name rather than met at the linker. *)
+  (* emscripten does ship an ASan, so the web half of this refusal is weaker
+     than the wasi half: it is untested here rather than known to be
+     impossible. Refused all the same, because a sanitizer that has never been
+     run is a sanitizer whose silence means nothing, and the sweep this project
+     runs (@sanitize) is native. *)
   if wasm_target opts && opts.sanitize then
     failwith
-      "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
-       wasm32-wasi to link against";
+      (if web_target opts then
+         "web: --sanitize is native only — emscripten has its own ASan, and \
+          nothing here has ever run it; the sanitizer sweep is the native one"
+       else
+         "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
+          wasm32-wasi to link against");
   (* -O0 is not a choice a debug build offers: [llvm.dbg.declare] describes an
      alloca, and at -O2 mem2reg deletes the alloca. [sanitize] deliberately
      does not do this: see [opts]. *)
@@ -399,8 +649,11 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   let objs =
     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 [])
+    (* wasi-libc's entry point, which is not [main]. See [wasm_main_source].
+       Not the browser's: emscripten's start code calls [main] under that name,
+       so the .ll's @main is already the entry point and the shim would be a
+       second definition of it. *)
+    @ (if wasi_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
     (* The generated half of the FFI: one translation unit holding a typedef
        per struct that crosses and a wrapper per (declare-c ...), compiled
        exactly like a package's hand-written .c. It rides on the program
@@ -414,7 +667,7 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   in
   let cmd =
     String.concat " "
-      ([ Filename.quote clang; opts.opt; "-Wno-override-module" ]
+      ([ Filename.quote (compiler opts); opts.opt; "-Wno-override-module" ]
        (* -g at the link so clang does not strip, and keeps the object files'
           debug sections; the .ll carries its own. The sanitizer flags have to
           be here too — they are what pulls in libclang_rt.asan and the UBSan
@@ -424,9 +677,12 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
        @ cflags opts
        @ (if opts.dev then [ "-rdynamic" ] else [])
        @ tflags
+       @ (if web_target opts then web_link_flags ~out else [])
        @ [ Filename.quote ll ]
        @ List.map Filename.quote objs
-       @ lflags
+       (* A package's linker arguments, with the lines addressed to another
+          target dropped and ${VAR} expanded. See [select_lflags]. *)
+       @ select_lflags opts lflags
        (* The prelude declares sqrtf, so every link needs libm. It goes here
           and not in the leading flags: the default --as-needed drops a
           library named before the object that wants it, so at -O2 this would
@@ -480,8 +736,9 @@ let shared ?(opts = default) ~ir ~out () : timing =
   let opts = if opts.debug then { opts with opt = "-O0" } else opts in
   if wasm_target opts then
     failwith
-      "wasm32: the reload path is native only — it is llc + ld -shared + \
-       dlopen, and wasm32 has no dlopen";
+      ((if web_target opts then "web" else "wasm32")
+       ^ ": the reload path is native only — it is llc + ld -shared + dlopen, \
+          and wasm has no dlopen");
   let dir = workdir () in
   let base = Filename.remove_extension (Filename.basename out) in
   let ll = Filename.concat dir (base ^ ".ll") in