The browser is a third target, and emcc is its driver

flan build --target=web produces a page, its JS and a .wasm. The two wasm
targets share the word and almost nothing else, so is_wasi and is_web are
separate predicates and is_wasm is their union — the union is exactly the
facts about the machine, 32-bit pointers and no dlopen, which is what the
refusals are about.

Everything the wasi target has to find by hand is what emcc already is: no
sysroot, no builtins archive, no shadow resource directory, and no
__main_argc_argv shim, because emscripten's start code calls main under that
name. target_flags for web is empty and the only thing checked is that emcc
exists. The one fact this rests on is that emcc takes a .ll on its command
line, so Emit's output needs no change.

The main loop is -sASYNCIFY rather than emscripten_set_main_loop, which
BUILT.md predicted. The prediction had the browser right and the cost wrong:
set_main_loop wants the loop body as a callback, so every example that writes
(until (rl/window-should-close?) ...) would be split by hand into an init and
a tick and would stop being the native program. raylib's web platform is built
for asyncify instead — WindowShouldClose on PLATFORM_WEB is an
emscripten_sleep(16) that returns false — so the loop yields at a call it
already makes and no example changed a character. Asyncify goes on every web
link, because whether a program blocks is not a question Build can answer and
a per-program flag set is a per-program cache key.

A link line may now be addressed to one target — @native, @wasi, @web — and
${NAME} expands from the environment. The selection is here and not in Load,
which reads the file, because Load resolves imports before a target is chosen.

The object cache now keys on whichever compiler the target uses, so an emcc
object and a clang one of the same source cannot collide. The refusals name
the target that was asked for; --sanitize on web says the weaker truth, that
emscripten ships an ASan and nothing here has ever run it.
This commit is contained in:
Joseph Ferano 2026-09-12 10:45:18 +07:00
parent 3afce2aeac
commit c2dc4d4244
2 changed files with 297 additions and 35 deletions

View File

@ -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 <file.flan> [-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) <file.flan>...\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] \
[--debug] [--sanitize] [--target=wasm32-wasi]\n\
[--debug] [--sanitize] [--target=wasm32-wasi|web]\n\
\ flan run <file.flan> [args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so]\n\
\ flan dev <program.flan> [-s socket]";

View File

@ -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|<!doctype html>
<!-- The smallest page that makes a Flan web build openable: a canvas for
raylib's GL context, and stdout on the page rather than only in the
console, because a headless check and a human reader want the same text.
emcc substitutes the module's JS for {{{ SCRIPT }}}; nothing else here is
emscripten's. Replace it with FLAN_WEB_SHELL. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>flan</title>
<style>
html, body { margin: 0; background: #14161a; color: #d8dee9;
font: 13px/1.5 ui-monospace, monospace; }
#canvas { display: block; margin: 0 auto; background: #000;
outline: none; }
#out { white-space: pre-wrap; padding: 8px 12px; }
</style>
</head>
<body>
<canvas id="canvas" tabindex="-1"
oncontextmenu="event.preventDefault()"></canvas>
<pre id="out"></pre>
<script>
var out = document.getElementById('out');
var Module = {
canvas: document.getElementById('canvas'),
print: function (t) { out.textContent += t + '\n'; },
printErr: function (t) { out.textContent += t + '\n'; },
};
</script>
{{{ SCRIPT }}}
</body>
</html>
|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