692 lines
48 KiB
Markdown
692 lines
48 KiB
Markdown
# Let's discuss
|
||
|
||
Open questions, raised and deliberately not answered yet. Nothing here is a decision or a task. Each entry is the
|
||
question as asked, plus what is already known in this repo that bears on it — so the investigation starts from what
|
||
exists rather than from scratch.
|
||
|
||
Settled decisions live in `NEXT.md`. Reasons for what already exists live in `BUILT.md`.
|
||
|
||
---
|
||
|
||
## 1. `i`, the inspector, and the frame it cannot see — answered and built
|
||
|
||
Answered, and built as option 2. `BUILT.md`'s "Two ways to root a walk, and why neither subsumes the other" is where
|
||
it lives now, including the correction to what this entry said about rooting at an address. The number is kept because
|
||
other files cite these by number; nothing here is open.
|
||
|
||
## 2. Annotating the IR and the disassembly with the source
|
||
|
||
**The IR half is nearly free and should just be done.** `emit.ml` writes `.ll` as text, so a comment costs nothing and
|
||
cannot break anything, and every typed IR node already carries a `Loc.t`.
|
||
|
||
**The disassembly half, with the optimisation question settled.** `objdump` already interleaves source into a listing
|
||
when DWARF is present (`-S`), and the daemon already shells out to objdump — so the `-O0` case is close to a flag.
|
||
|
||
Settled in conversation: **`-O0` is expected to follow the source and gets the full annotation; `-O2` is not expected to
|
||
and gets either nothing or whatever best-effort mapping falls out.** That removes what looked like the blocking
|
||
question. `--debug` forcing `-O0` is therefore fine and does not need decoupling for this.
|
||
|
||
**How SBCL does it, since it came up.** SBCL does *not* shell out. `sb-disassem` is its own disassembler, written in
|
||
Lisp, that knows the instruction encodings directly. What that buys is annotation from the inside: it labels constants
|
||
the function references, names the functions being called, marks entry points, and shows its own calling conventions —
|
||
because it compiled the code object and still holds the metadata.
|
||
|
||
The relevant observation is that **this project is closer to SBCL's position than the objdump route suggests.** The
|
||
daemon owns the build and holds the metadata too; it simply is not feeding much of it into the listing yet. Naming the
|
||
function behind an indirection cell, or a constant by its source name, needs no instruction decoder — only the
|
||
information the daemon already has. Writing a disassembler is not the interesting part and should stay off the table;
|
||
richer annotation of objdump's output is cheap and is where SBCL's advantage actually comes from.
|
||
|
||
## 3. `def`, `defvar`, `defconst` — three roles, currently two words
|
||
|
||
There is no `def`. `defvar` is a mutable global that is always re-initialised on redefinition; `defconst` is a
|
||
compile-time constant, folded into its use sites.
|
||
|
||
The problem in practice: `C-c C-k` re-evaluates every top-level form against a *running* program, so today it wipes
|
||
state you may have spent a session accumulating.
|
||
|
||
**Settled in conversation — three roles, three words:**
|
||
|
||
- **`def`** — always re-initialised. The tweakable: some number you are adjusting and do not care about preserving.
|
||
- **`defvar`** — re-initialised *only if it would come out different*. For heavier initialisers: an N×M grid that should
|
||
recompute only when the rows or columns actually changed.
|
||
- **`defconst`** — folded, and reserved for what genuinely cannot change for the life of the program. `rows`/`cols` in
|
||
`sand.flan` are honest `defconst`s: the program does not support resizing on the fly and changing them means resetting
|
||
everything anyway.
|
||
|
||
Note this **`defvar` is not Common Lisp's**, which is "assign only if unbound". This one is value-dependent, which suits
|
||
a live-edited game better and is the harder of the two to implement.
|
||
|
||
**The open question is what "different" means**, and the grid example is what makes it sharp. If the grid is sized by
|
||
`rows` and `cols`, the initialising expression's *text* is unchanged when `rows` changes — only its value is. So:
|
||
|
||
1. **Compare the expression** (textual or AST). Cheap, and does not catch the case above.
|
||
2. **Compare the expression plus everything it depends on.** Catches it. Needs dependency tracking.
|
||
3. **Evaluate and compare the result.** Correct, and defeats the entire purpose whenever the initialiser is expensive —
|
||
which is the only case this feature exists for.
|
||
|
||
Option 2 is what is wanted.
|
||
|
||
**The reason to do it: it is the same machinery `defconst` needs.** A folded constant cannot be tuned live, because its
|
||
value is baked into every function that used it — so the values you most want to tweak while the game runs are exactly
|
||
the ones you cannot. Fixing that means knowing which functions depend on which constants, and rebuilding those. That is
|
||
the same dependency tracking `defvar`'s "if different" requires. Built once, it buys live-tunable constants *and* the
|
||
conditional re-initialisation. Neither is worth building alone; together they are clearly worth it.
|
||
|
||
## 4. Structural typing, row polymorphism, anonymous structs
|
||
|
||
The goal as stated: make this feel Clojure-like while still being typed.
|
||
|
||
**The representation is already structural.** `types.ml` does structural equality on resolved types, and a Flan struct is
|
||
exactly its C layout with no header and no tag word. What is nominal is the *checking*, not the data — so this is a
|
||
front-end question, not a data-model change.
|
||
|
||
**What it buys, in rough order of value here:**
|
||
|
||
1. **Your destructuring already looks like this, and that is the strongest argument.** Clojure's `{:keys [x y]}` works in
|
||
binding position today. Structural typing makes the same notation work in *parameter* position — the pattern you write
|
||
to pull fields out becomes the signature saying what you accept. One notation, two places, no separate declaration.
|
||
That is precisely the "Clojure-like but typed" feel being asked for, and it needs no new syntax, only a new meaning
|
||
for syntax that exists.
|
||
2. **Functions over "anything with these fields".** Magnitude over anything with an `x` and a `y` — a position, a
|
||
velocity, an enemy, a bullet. In a game this recurs constantly because everything has a position.
|
||
3. **Ad-hoc returns.** Returning a hit flag and a point without declaring a type for the pair. The Clojure habit of
|
||
returning a map, typed and free.
|
||
4. **The FFI stops needing conversions.** raylib's `Vector2` and a locally-defined vector are byte-identical and are
|
||
today two nominal types. Structurally they are one.
|
||
|
||
**Two costs, and the second is the real work:**
|
||
|
||
- **Not an "explicit over convenient" question — that framing was wrong and is recorded so it is not repeated.** The
|
||
standing rules about implicit numeric conversion and about a bare integer not becoming an enum are about *lossy*
|
||
things happening silently. A structural match is not lossy; it is a different notion of type *identity*. The two were
|
||
lumped together in conversation and the author rejected the pairing, correctly. Strong typing and nominal typing are
|
||
separate choices and this only touches the second.
|
||
- **Field order is layout, and that is the hard part.** `{x f32, y f32}` and `{y f32, x f32}` have the same fields and
|
||
different memory. So either field order becomes significant — surprising, since nothing else about a structural type
|
||
should be — or matching a differently-ordered type requires a copy, which breaks the zero-cost property the whole data
|
||
model rests on. Accepted in conversation as a limitation to respect rather than fight.
|
||
|
||
**Writability is what decides whether the copy escape exists.** Fields would be writable on the same terms as any struct:
|
||
by value you get a copy and writes are local; through a `(Ptr T)` they reach the original. But that settles the layout
|
||
question rather than sitting beside it. *Read-only* structural access has an out — the compiler could gather the
|
||
requested fields into a temporary, and then field order would not matter at all. *Writable* structural access cannot:
|
||
it has to alias the real storage, so the layout must genuinely line up. Decide writability first; the layout answer
|
||
follows from it.
|
||
|
||
**`defclass` may solve this, for the cases that can afford it.** A class has an implementation-defined representation, so
|
||
the compiler owns the layout and access goes through metadata rather than a fixed offset — field order stops being a
|
||
user-visible fact. The limit is that a class carries identity and metadata, and a `Vector2` should not pay for either.
|
||
So plain structs still need their own answer and classes only cover the cases where the overhead was already accepted.
|
||
See item 7.
|
||
|
||
Also unresolved: the shim generates a C typedef per *named* struct, and an anonymous struct has no name to generate one
|
||
from. Either anonymous structs cannot cross the FFI, or the generator learns to name them.
|
||
|
||
## 5. A JS backend — for web apps, not for games
|
||
|
||
**The goal, stated in conversation: calling JS libraries, and eventually a hiccup-style DSL for writing web apps in
|
||
Flan.** That is a different product from the wasm target and the two do not compete — games go to wasm, web apps go to
|
||
JS.
|
||
|
||
This matters because the obvious objection does not apply. "A JS backend cannot call raylib, so the whole graphics layer
|
||
would need reimplementing on canvas" is fatal for a *game* backend and irrelevant here: web apps do not use raylib.
|
||
|
||
**The design fork, and it should be decided first:**
|
||
|
||
- **Linear memory** — an `ArrayBuffer` standing in for pointers and structs, with typed-array reads and writes. Fast
|
||
arithmetic, and every value is opaque bytes, so every call into a JS library marshals both ways. This is asm.js, which
|
||
wasm exists to replace, and it is precisely wrong for an interop-motivated backend.
|
||
- **Object mapping** — a Flan struct becomes a plain JS object, a `Vec` becomes a JS array. Slower for tight numeric
|
||
loops, natural for interop, readable output. **For this goal this is clearly the right one**, and performance is not
|
||
the constraint people assume: DOM work is dominated by the browser, not by arithmetic.
|
||
|
||
**The consequence to face early: the memory model does not come along.** Object mapping means garbage collection, which
|
||
means no pointers, no manual `free`, no arena, no allocator. So this is a **dialect**, not merely a second backend —
|
||
some Flan programs will not compile to JS, and that should be named up front rather than discovered. Worth deciding
|
||
which subset is the JS-targetable one, and whether the checker enforces it per target.
|
||
|
||
**The unexpected upside: the dev loop could work on the web.** Conditions, restarts and a break loop all need the
|
||
ability to run new code in a stopped program. wasm cannot have this — `--dev` is refused there because reload is
|
||
`dlopen` and the browser has no sockets. JS has no such problem: it evaluates new code trivially. A JS target might be
|
||
the *only* place the live dev loop and the browser coexist.
|
||
|
||
**Reader conditionals, decided in conversation.** A cljc-style subset is accepted, and the author chose Clojure's
|
||
inline reader conditionals over file-level target naming. Note what that changes: **this language has no conditional
|
||
compilation today**, and that absence drove a decision earlier the same day — `barf` signals on web rather than being
|
||
compiled out, precisely because "this bit is desktop-only" is not expressible. Note also that the sand-on-web work
|
||
introduced file-level target replacement for *C* sources (`foo.web.c` replaces `foo.c`), so the repo now has a
|
||
file-level precedent that this decision deliberately does not follow. Both were put; inline was chosen.
|
||
|
||
**Also wanted, and independent of all of the above:** `#_` to discard the next form, and repeated (`#_#_`) to discard
|
||
that many following forms, as in Clojure. Reader-only, small, and useful immediately rather than whenever a JS backend
|
||
happens.
|
||
|
||
**One dependency:** hiccup is a macro, and the macro expander is blocked on `Form` being a Flan union, which is union
|
||
values. The backend can start before that; the DSL cannot.
|
||
|
||
## 6. C interop as seamless as Zig's — built, with two decisions left
|
||
|
||
**The mechanism is in** (`lib/cimport.ml`, `lib/cjson.ml`, `vendor/raylib/headers`; BUILT.md, "The header
|
||
is read now"). Settled and not worth reopening: clang's JSON AST dump over a shelled-out `clang`, never
|
||
libclang — and Zig has since abandoned linking clang too, for Aro, which strengthens the argument rather
|
||
than weakening it. The import is bounded by the package's own `defstruct`s rather than by a curated list.
|
||
Refusals are demotions in Zig's sense: the name exists, cannot be had, and says why at the use site, which
|
||
`Load.refuse_hidden` already did for `main`. Names kebab by a rule that is injective over raylib's 581, and
|
||
reversibility is by storage — the C symbol is kept verbatim — so the rule never needs an inverse. Where two
|
||
names do collide, neither takes it.
|
||
|
||
**The evidence:** against raylib 5.5, all 16 `defstruct`s and all 172 hand-written `declare-c` agree
|
||
exactly. Against the 5.1-dev header also on this machine, ten real differences. Both comparisons stop the
|
||
build, and a permuted `Texture2D` or an `f64` for a `float` is caught by name.
|
||
|
||
What is left is two decisions, and both are the author's.
|
||
|
||
### 6a. Does reading the header stay a build-time step, or become a code generator?
|
||
|
||
The cost, measured, with the wrappers pruned by `Reach` as they already were:
|
||
|
||
| | today | + 256 imported |
|
||
|---|---|---|
|
||
| release build, warm | 0.078s | 0.082s |
|
||
| **redefinition** | **31.0ms** | **46.5ms** |
|
||
| dev build, cold | 0.649s | 0.982s |
|
||
|
||
Release is nearly free and that question is answered. **The 15.5ms on redefinition is not nothing on the
|
||
branch where the dev loop is the priority** — it is a 50% increase on the number that lane exists to keep
|
||
small, and it buys a check of signatures that have not changed since the last build.
|
||
|
||
So the third option from the original discussion is now the live one: `flan import-c` already prints
|
||
`declare-c` lines, so **generate from the header, commit the result, regenerate when raylib moves** costs
|
||
nothing more to build. Explicit in the source, checked against reality, no header read at build time, and
|
||
the check becomes a thing you run rather than a thing you pay for. Against it: a committed file goes stale
|
||
silently, which is the failure the whole lane exists to prevent, and "regenerate when the library moves"
|
||
is a discipline rather than a mechanism.
|
||
|
||
A middle reading worth considering: keep the build-time check but run it only when *not* `--dev`, on the
|
||
grounds that a release build is where a wrong signature must not get through and a dev build is where
|
||
15.5ms is felt. That is the same shape as `Reach` not pruning dev builds, for a symmetric reason.
|
||
|
||
### 6b. Do the 172 hand-written lines get migrated?
|
||
|
||
The diff is clean, so nothing blocks it on correctness. What blocks it is that migration needs the header
|
||
present at *every* build, which means vendoring raylib.h into the repo or requiring `raylib-devel` — and
|
||
BUILT.md records "a build needs libraylib linkable and not raylib-devel installed" as a property that was
|
||
chosen on purpose. That is why `vendor/raylib/headers` is opt-in (`?${FLAN_RAYLIB_H}`) today and the
|
||
hand-written lines are untouched.
|
||
|
||
Worth noting what migration would actually lose, since it is small but real: the hand-written names are
|
||
better than the rule's. `IsKeyPressed` is `key-pressed?` by hand and `is-key-pressed` by rule;
|
||
`CheckCollisionRecs` is `collision-recs?`. And an enum parameter imports as `i32`, because the header says
|
||
`KeyboardKey` and nothing tells the importer the package calls that `Key` — so `(rl/key-down? :space)`
|
||
would become an integer at the call site. A migration is therefore not a deletion; it is a deletion plus a
|
||
kept list of the lines whose face is deliberately nicer than the header's.
|
||
|
||
## 7. Watching variables
|
||
|
||
Raised while designing item 1, and deliberately separated from it.
|
||
|
||
Item 1 shows globals *per frame*, chosen by what the code in that frame references. That is the right default and it
|
||
cannot cover the case where the thing you care about is not named by the frame you are standing in — stopped deep in a
|
||
helper, still wanting to see the grid.
|
||
|
||
So: a way to say "always show me this", surviving a resume and the next break.
|
||
|
||
Questions it opens:
|
||
|
||
- **What can be watched.** A global is easy — it has a name and an address that does not move. A *local* is harder: it
|
||
belongs to a frame that is gone as soon as you resume, so "watch `y`" either means a different `y` every time or means
|
||
nothing. A watched expression — `(at velocity 40 12)` — is the most useful and the most expensive, since it has to be
|
||
compiled and run in the program each time, which is what `eval-expr` already does.
|
||
- **Where the list lives.** Per project, per session, or in the file. A watch list that vanishes when Emacs restarts is
|
||
one you stop using; one in the repo is one you accidentally commit.
|
||
- **When it updates.** Only on entering a break is cheap and probably enough. Live-updating while the program runs is a
|
||
different feature — closer to a HUD than a debugger — and worth not conflating.
|
||
- **Whether it belongs in the break buffer at all**, or in its own window that is useful while the program is *running*,
|
||
which is arguably where a game developer wants it.
|
||
|
||
## 8. Indentation is wrong inside a binding vector
|
||
|
||
Reported against the `let` in `sand.flan`'s `settle`: the second and later bindings indent one column too far.
|
||
|
||
**Cause, found by reading `flan-mode.el`.** `flan-indent-function` looks at the head of the *enclosing* form and, when
|
||
it is one of Flan's body forms (`defn`, `let`, `if`, `while`, `until`, `dotimes`, `match`, `do`, `loop`, `defer`),
|
||
indents one past the open paren. Inside a `let`'s binding *vector* the enclosing open is the `[`, and the symbol after
|
||
it is the first binding's **name** — never a body form — so the check fails and it falls through to Emacs's generic
|
||
`lisp-indent-function`, which treats the vector as a function call and aligns continuation lines under the first
|
||
*argument* rather than under the first *binding*.
|
||
|
||
**What it is built on:** Emacs's built-in `lisp-indent-function`, with that one override. Not `clojure-mode`, which is
|
||
where the missing piece lives — `clojure-mode` special-cases binding vectors and aligns them as pairs.
|
||
|
||
**The fix, agreed in conversation:** add a binding-vector case to `flan-indent-function`. When the enclosing open is `[`
|
||
and the form containing it is a binding form, align continuation lines to the column of the **first binding**, not to
|
||
the first argument. Bindings are pairs, so the alignment that reads correctly is name-under-name. Check the other
|
||
bracket users in the same pass — `defn` parameter lists and `restart-case` clause parameters have the same shape and
|
||
almost certainly the same bug.
|
||
|
||
**`clojure-mode` should be the reference, not the ancestor and not a dependency.** The distinction matters and was
|
||
muddled once in conversation: the proposal is *not* `define-derived-mode` on `clojure-mode`, and not adding an external
|
||
package. It is to rewrite `flan-mode`'s indentation and font-lock as our own code, **ported from `clojure-mode`'s source
|
||
rather than from `lisp-mode`'s**.
|
||
|
||
The current mode has the wrong ancestor. It is built on Emacs's `lisp-indent-function`, and Emacs Lisp has none of the
|
||
shapes Flan actually uses: no vectors as binding forms, no keywords as map keys, no destructuring, no bracket variety.
|
||
So every rule has to be added by hand and the binding-vector bug is simply the first one hit. Clojure's rules already
|
||
cover brackets-mean-binding, pairs-align, maps, keywords, `#_`, and reader conditionals — the last two now wanted in
|
||
their own right.
|
||
|
||
Where Flan diverges it diverges deliberately rather than by discovery: `defn` carries a return type between the
|
||
parameters and the body; field access is `(.x v)`; field labels are moving from `:x` to `.x`, so `{.x 1.0}` is a struct
|
||
literal and not a map with symbol keys. Those get written on purpose.
|
||
|
||
Deriving at runtime stays rejected — it would add an external dependency to a mode that ships inside this repo and
|
||
currently needs nothing beyond stock Emacs, and that community is mid-transition to a tree-sitter mode, so the base is
|
||
moving.
|
||
|
||
## 9. Instrumenting with `(pause)` from Emacs, without editing the buffer
|
||
|
||
Requested, not scheduled. The Clojure model: `C-u` before an eval marks the form so it stops when it runs. Wanted here
|
||
for **three targets** — the top-level form, the last expression, and **the form point is inside**, so that with the
|
||
cursor at `(+ 1| 1)` the program stops at that `(+ ...)`.
|
||
|
||
**What already exists, so this is mostly assembly:**
|
||
|
||
- **`(pause)` itself**, landed today in `lib/prelude.ml`. It is `error` under a `restart-case` with a `continue`
|
||
clause — a breakpoint is not a compiler feature here, it is what a condition system already gives you. Taking
|
||
`continue` resumes at the call.
|
||
- **`flan-eval-defun`** takes the top-level form's bounds and sends the text; **`flan-eval-last-sexp`** sends the sexp
|
||
before point. Both hand a *string* to the daemon, which is the hook — the buffer never has to be modified.
|
||
- **`flan-dev--enclosing-head`** (`flan-dev.el:1009`) already walks out to the enclosing form to find its head, for
|
||
eldoc. The third target's hard part is already written.
|
||
- `current-prefix-arg` is already read in one place (`flan-dev.el:1429`), so the `C-u` convention has precedent.
|
||
|
||
**So the work is a source-to-source rewrite in Emacs before sending:** find the target form's bounds, send the
|
||
top-level form with that span replaced by `(do (pause) <span>)`. Nothing on the compiler side changes.
|
||
|
||
**The one real difficulty: source locations.** Splicing text shifts every line and column after the insertion, so
|
||
error overlays, `layout`, the break loop's frame locations and DWARF would all point slightly wrong for an
|
||
instrumented form. Options, none free: pad the inserted text to preserve offsets (ugly, fragile across newlines); send
|
||
the instrumentation as a separate field the daemon splices *after* parsing, where locations are already attached; or
|
||
accept the drift and say so. **The second is almost certainly right** and makes this a small daemon change rather than
|
||
a pure-Emacs one — which also means it should not be built as a pure-Emacs hack first.
|
||
|
||
**Settled: it sticks, like Clojure's.** The mark stays until the form is evaluated again plainly. That is the
|
||
behaviour the workflow expects — you mark it, run the game, hit it repeatedly, and clear it with an ordinary `C-c C-c`
|
||
rather than having to re-mark before every run.
|
||
|
||
## 10. A second backend for dev builds — study SBCL and drop LLVM there?
|
||
|
||
Raised at the end of the session, not answered. The proposal: write our own code generator for **debug builds and the
|
||
interactive workflow**, keep LLVM for release. The motivation is that evaluating arbitrary code — full functions, real
|
||
test code, not just constant arithmetic — costs a compile, a file and a `dlopen` every time.
|
||
|
||
**What SBCL actually does**, since it is the model: its own assembler and code generator, emitting machine code
|
||
straight into the heap. No files, no external tools, no dynamic loading. Code objects are ordinary heap objects and are
|
||
collected when unreachable. That is also why its `disassemble` can annotate richly and why it can show machine state —
|
||
it owns everything.
|
||
|
||
**The argument against, and it is the same one that killed the interpreter.** Two backends must agree about arithmetic,
|
||
struct layout, overflow, evaluation order and calling convention. When they disagree you get *works interactively,
|
||
breaks when shipped* — the worst bug class in a live-programming system, and arguably worse than the interpreter
|
||
version because the divergence is in code generation rather than in semantics you can read.
|
||
|
||
Note what plan.org's dev-vs-release table already accepts: indirection cells, a shadow stack, version words, code
|
||
never freed. Those are **additive instrumentation on one code generator**, not a second one. This proposal is a
|
||
different kind of divergence.
|
||
|
||
And the cost is enormous: an instruction selector and assembler per architecture — x86-64, arm64, wasm32 — which is
|
||
where SBCL's decades went.
|
||
|
||
**The cheaper route to the same goal, and the thing to weigh it against: ORC, LLVM's in-process JIT.** Measured today,
|
||
a 35ms redefinition is **21ms of `llc` plus `ld`** — external tools generating and linking machine code. An in-process
|
||
JIT replaces exactly that, with **the same IR and therefore the same semantics**, no second implementation, and code
|
||
that can be unloaded. It gets what the proposal wants without the divergence hazard.
|
||
|
||
plan.org already says ORC "remains addable later behind the same typed IR without touching the language", and the only
|
||
column text-IR-plus-clang loses in its comparison table is the JIT one.
|
||
|
||
**So the honest position:** the thing that would justify revisiting the LLVM decision is not slower eval in the
|
||
abstract — it is wanting *arbitrary interactive evaluation of real code* as a first-class workflow. That is now stated
|
||
as a want. If it stays a want, ORC is the answer and a hand-written backend is not.
|
||
|
||
**Where the shared objects live**, since it was asked: `Build.workdir ()` — a per-build temporary directory — with the
|
||
object cache separately at `Build.cachedir ()`, stable across builds. Nothing sweeps the workdir, and nothing is ever
|
||
`dlclose`d, so a long session accumulates both files and mappings. Sweeping at session end is cheap and unrelated to
|
||
any of the above.
|
||
|
||
## NEXT SESSION STARTS HERE
|
||
|
||
**The agreed plan: investigate putting the compiler inside the running program.**
|
||
|
||
Today there are two programs. The *daemon* is the compiler. The *game* is a separate program it launches. They talk
|
||
over a socket, so everything the editor shows — locals, globals, the stack, evaluation results — has to be packed up
|
||
in one program, sent across, and unpacked in the other. A large share of what `BUILT.md` documents exists only to move
|
||
data between those two address spaces.
|
||
|
||
Putting the compiler inside the game removes all of that. There is nothing to send, because the compiler can read the
|
||
program's memory directly. It is what SBCL does, and it is why interactive work in SBCL is instant.
|
||
|
||
**Read, in this order:** item 13 (what is actually being decided, and why the order matters), then item 11 (the shape,
|
||
the two objections and how the author answered them), then item 12 (the specific questions a spike must answer).
|
||
|
||
**First task, and it is research not building:** the spike in item 12 — what it costs to link the OCaml compiler into
|
||
a dev build. OCaml 5.2's multicore runtime has already removed the objection that would have been fatal. If it embeds
|
||
acceptably, everything else is reachable.
|
||
|
||
**Do not start with the backend question.** Merge first, measure, then choose. Item 13 says why.
|
||
|
||
---
|
||
|
||
## 11. One process, not two — the biggest open question
|
||
|
||
Raised at the very end of the session and **not resolved**. It subsumes items 9 and 10, and possibly several decisions
|
||
recorded elsewhere as settled. Do not treat any of those as closed until this is answered.
|
||
|
||
**The claim:** the daemon and the running program should not be two processes. SBCL is one image; Clojure is one
|
||
image; in both, the interactive workflow is cheap *because nothing is transported*. The author's reading is that the
|
||
split here was forced by not having threads — and that is worth checking, because **threads are already in use**: the
|
||
agent runs a listener thread and a loader thread inside the program today.
|
||
|
||
**The concrete shape**, if it were done: load the *program* into the daemon's process as a shared library rather than
|
||
launching it as a child. The reload primitive already `dlopen`s modules; the program itself would be one.
|
||
|
||
**What would evaporate, and it is a lot.** No socket and no wire protocol. Cell updates become plain pointer stores.
|
||
Locals and globals become memory the compiler can read directly — no render thunks compiled per inspection, no 4K
|
||
result cap, no seqlock, no snapshot copying, no generation stamping. The macro expander stops needing the compiler's
|
||
own `dlopen`. `flan reload` stops being a fresh process paying startup every time. A large amount of machinery
|
||
recorded in `BUILT.md` exists **only** to move data between two address spaces.
|
||
|
||
**What is given up, in order of sharpness:**
|
||
|
||
1. **Crash isolation.** A segfault in the game currently cannot touch the compiler. `plan.org`'s jank note — corrected
|
||
earlier for citing the wrong mechanism — concludes "we are safe from the repro *because we compile out of process*".
|
||
In one image a bad pointer takes the session with it. Lisp users live with this and mostly accept it; it is a real
|
||
loss and should be chosen knowingly, not discovered.
|
||
2. **macOS requires a window on the main thread.** "Run raylib on a separate thread" is fine on Linux and not portable.
|
||
The inversion — compiler on the side thread, window on main — probably works, but it needs deciding rather than
|
||
assuming, and it changes who owns the main loop.
|
||
3. **The OCaml runtime and the game in one address space**: signals, `SIGSEGV` handling for the break loop, the OCaml
|
||
GC running while game code holds raw pointers into its own arenas. Each is probably fine; none is free.
|
||
|
||
**What it does NOT fix, and this is where an earlier answer in this session was wrong.** A hand-written debug backend
|
||
was discussed as the way to make evaluation instant. The correction: a new backend removes `llc` and `ld` — about
|
||
21ms of a 35ms redefinition — but **not the transport**, because the transport exists due to the process split, not
|
||
the backend. Conversely, **merging the processes removes the transport but not the code generation**. They are
|
||
independent, and merging is the larger prize of the two.
|
||
|
||
**Decisions to revisit if this is taken:** the watch design (push was chosen partly because polling costs a compile);
|
||
the 4K result cap and its seqlock; the snapshot machinery and its generation stamping; the render-thunk-per-inspection
|
||
design for locals and globals; the inspector's inability to retain a value; and whether ORC or a hand-written lowering
|
||
is needed at all.
|
||
|
||
**Both objections were answered by the author, and the second reframed the design — record the corrected shape.**
|
||
|
||
**Crash isolation: accepted knowingly.** Conditions already catch what the *language* signals — a failed bounds check,
|
||
an exhausted allocator, a missing file — and those keep stopping in the break loop in one process exactly as they do
|
||
now. What they cannot catch is a real memory fault: a bad pointer through the FFI, a use-after-free in an arena. That
|
||
is an OS signal, not a condition, and it takes the image with it. Same deal every Lisp makes.
|
||
|
||
**macOS: invert the relationship, and this is the right shape rather than a workaround.** Not "load the program into
|
||
the daemon" as sketched above — the opposite. **The agent is already a server running inside the program.** So the
|
||
compiler moves *into the program's process*, beside the listener that is already there. The game starts, takes the
|
||
main thread for its window, and the compiler and listener run on a side thread. That is SLIME's model: you start the
|
||
image, it serves, the editor connects.
|
||
|
||
This also fits a distinction `plan.org` already draws — dev and release builds are deliberately different. A dev build
|
||
links the compiler in; a release build ships neither compiler nor server. The same split SBCL has between a
|
||
development image and a delivered executable.
|
||
|
||
One thing that gets *better* rather than merely cheaper: a break loop in the same process as the compiler can offer
|
||
restarts that recompile and retry, with no transport in between.
|
||
|
||
**Suggested next step:** the macOS question is answered, so start instead with what it costs to link the OCaml compiler
|
||
into a dev build — the OCaml runtime and the game sharing an address space, signal handling for the break loop, and the
|
||
GC running while game code holds raw pointers into its own arenas. It is the only one of the three costs that could
|
||
make the whole idea non-portable, and it is answerable by reading raylib and GLFW rather than by building anything.
|
||
|
||
## 12. What it costs to put the OCaml compiler in the game's process — and whether to port the compiler
|
||
|
||
Follows from item 11. **Investigate before deciding anything**; this entry is the brief, not the answer.
|
||
|
||
**One fact established already: this project is on OCaml 5.2.** That is multicore OCaml — real threads, domains, no
|
||
global runtime lock. It removes the objection that would have been fatal: the runtime can sit on its own domain beside
|
||
the game's threads rather than serialising everything.
|
||
|
||
**What to find out:**
|
||
|
||
- **Linking OCaml into a C/native binary.** `ocamlopt -output-obj` and friends are supported; how well, with dune, with
|
||
the C stubs this project already has (`dynload_stubs.c`, the runtime, the shims)?
|
||
- **Signals.** The OCaml runtime installs handlers. The break loop wants `SIGSEGV` for the crash case, and the agent
|
||
already handles socket work. Who wins, and can they coexist?
|
||
- **The GC and raw pointers.** OCaml's collector moves its own heap; Flan's arenas and `Vec`s are raw memory OCaml
|
||
never sees. That should be fine — they do not point at each other — but "should be" is not an answer.
|
||
- **Startup cost and binary size** for a dev build that links the whole compiler.
|
||
- **Whether the main thread can be the game's** while the compiler runs on a domain (see item 11's macOS note).
|
||
|
||
**And the larger question the author raised: port the compiler to another language?**
|
||
|
||
The honest end state for a Lisp that wants one image is **self-hosting** — the compiler written in Flan. That is what
|
||
SBCL is, and it dissolves this entire question: no foreign runtime to embed, no GC to reconcile, no signal conflict,
|
||
and the compiler becomes the largest test of the language. It is also an enormous undertaking and needs macros,
|
||
unions, `Map` and a string library first — three of which landed today.
|
||
|
||
Intermediate options if OCaml proves genuinely hostile: C or C++ (no runtime to embed, painful to write a compiler
|
||
in), Rust (no GC, embeds cleanly, large rewrite), or Zig (same, and its own C-interop story is already being read for
|
||
item 6). **None of these should be entertained on aesthetics.** The bar is a specific, demonstrated obstacle to
|
||
embedding OCaml 5.2 — and the multicore runtime means the most likely obstacle has already gone away.
|
||
|
||
**Order of work:** answer the embedding questions above with a spike, not a rewrite. If OCaml embeds acceptably, item
|
||
11 is buildable and no port is needed. Self-hosting stays a long-term ambition rather than a prerequisite.
|
||
|
||
## 13. The two questions are independent, and the order matters
|
||
|
||
The end of the session, and the clearest statement of what is actually being decided. Read this before items 10, 11
|
||
and 12, which were each written mid-argument.
|
||
|
||
**Two separate decisions, repeatedly conflated in conversation:**
|
||
|
||
1. **One process or two** — whether the compiler runs inside the program's process. This is about *transport*: how
|
||
compiled code and inspection data move between the compiler and the running program.
|
||
2. **How machine code is produced and installed** — the current `llc` + `ld` + `dlopen`, libLLVM's in-process JIT
|
||
(ORC), or a hand-written lowering. This is about *code generation*.
|
||
|
||
Neither implies the other. A hand-written backend removes `llc` and `ld` (about 21ms of a 35ms redefinition) and
|
||
leaves the transport. Merging the processes removes the transport and leaves code generation exactly as it is.
|
||
**Merging is the larger prize.**
|
||
|
||
**And there is a third option for question 2 that keeps getting lost: change nothing.** In one process you can still
|
||
write a `.so` and `dlopen` it. You lose nothing that works today, and you still delete the socket, the wire protocol,
|
||
the 4K result cap, the seqlock, the snapshot copying, the render-thunk-per-inspection, and `flan reload` paying process
|
||
startup every time.
|
||
|
||
**So the order is:**
|
||
|
||
1. **Spike the embedding** (item 12). OCaml 5.2's multicore runtime removes the objection that would have been fatal.
|
||
If it embeds acceptably, everything below is reachable.
|
||
2. **Merge the processes**, keeping the existing code path. Biggest win, no new dependency, deletes the most
|
||
machinery.
|
||
3. **Measure what is left.** With transport gone, a redefinition is whatever code generation costs. That may simply be
|
||
fine.
|
||
4. **Then decide the backend, on evidence.** libLLVM's JIT means linking the library `plan.org` rejected — for
|
||
reasons that were right at the time, when the JIT was worth ~13ms and nothing else. In one process it is worth the
|
||
file, the load, the unbounded accumulation *and* cheap evaluation of arbitrary real code, which the author has since
|
||
named as a first-class want. The cost is also more bounded than "breaks routinely" suggests: the bindings track
|
||
LLVM major versions, so it is a pin you own and upgrade deliberately.
|
||
|
||
A hand-written lowering buys the same speed plus no dependency and a disassembler you own, at the cost of an
|
||
instruction selector per architecture, maintained forever. The drift risk is real but testable — the `@sanitize`
|
||
alias already builds 28 programs twice and compares output and exit status, which is exactly the harness two
|
||
backends would need.
|
||
|
||
**The principle to keep:** the original decision was made on a measurement and it held up for months. Make this one the
|
||
same way — merge first, measure, then choose. Do not pre-empt step 4 at step 1.
|
||
|
||
**Also settled in passing:** a release build can include the compiler and server and simply not be connected to, the
|
||
way an SBCL executable can. The dev/release split is a build flag, not an architectural constraint.
|
||
|
||
**And one correction to keep:** OCaml's own wasm support is irrelevant to any of this. OCaml is the compiler's
|
||
implementation language; Flan programs reach wasm through LLVM. The two only meet if the *compiler* should run in a
|
||
browser, which is not a goal.
|
||
|
||
## 14. The embedding spike, answered: OCaml 5.2 goes into a Flan dev build, and nothing objects
|
||
|
||
Item 12's brief, run. **Feasible.** No obstacle was found that argues for porting the compiler, and the one expected to
|
||
be sharpest — signals — turned out not to exist on the platform measured.
|
||
|
||
The apparatus is `spike/embed/`: four shell scripts and sixteen small sources, deliberately not a dune target, driving
|
||
`ocamlfind` and `clang` by hand against the `flan.cmxa` dune already builds. `bash spike/embed/run.sh` reproduces
|
||
everything below; `sig.sh`, `symbols.sh` and `merged.sh` each answer a question on their own. Nothing under `spike/` is
|
||
wired into the build, and `dune test --root .` is green either side of it.
|
||
|
||
### The headline: one binary, and it compiles itself
|
||
|
||
`spike/embed/merged.sh` builds a single executable out of, in one `clang` link:
|
||
|
||
- the emitted Flan program (`test/programs/edn.flan`, `--dev`, `@main` renamed to `flan_program_main`),
|
||
- `runtime/flan_rt.c`, `runtime/flan_dev.c`, `vendor/agent/flan_agent.c`,
|
||
- and the entire OCaml compiler as one `-output-complete-obj` object.
|
||
|
||
It runs. A C `main()` holds the main thread and runs the Flan program there; `caml_startup` happens on a `pthread`
|
||
beside it, next to where `flan_agent_start` already puts its listener. The compiler inside the binary then compiles
|
||
`edn.flan` — the source the program itself was built from — and emits 336,579 bytes of LLVM IR. That single result
|
||
answers questions 1 and 2 together; the ladder of smaller probes underneath it is support, not evidence in its own
|
||
right.
|
||
|
||
Nothing is wired up. The two halves share an address space and do not speak to each other. That is the point: the
|
||
question was whether they *can*, not what they would say.
|
||
|
||
### Question 1 — does OCaml link into a native binary here?
|
||
|
||
Yes, and with less friction than expected.
|
||
|
||
- `ocamlopt -output-complete-obj` is the one to use, not `-output-obj`: it bundles the runtime, so there is no hunt for
|
||
`libasmrun`. The final link needs `-lm -lpthread -ldl` and, on 5.2, **`-lzstd`** — 5.x's marshaller is compressed,
|
||
and the missing `ZSTD_*` symbols are the first thing a naive link fails on. That is the whole of the surprise.
|
||
- **dune is not in the way, because it does not have to be involved.** dune builds `lib/flan.cmxa` as it does today;
|
||
the `-output-complete-obj` step consumes that artifact afterwards. No dune rule had to change, and `lib/dune` and
|
||
`bin/dune` are untouched. A real merge would want a dune rule to drive that step, but the spike shows the artifact
|
||
boundary is clean, which is the part that could have failed.
|
||
- **The existing C stubs come through.** `lib/dynload_stubs.c` was taken verbatim from the unmerged `9e0ae3a` dlopen
|
||
branch and compiled into the same object; from inside the embedded runtime, `flan_mem_alloc`/`poke`/`peek`
|
||
round-trip correctly and `dlopen`+`dlsym` work. `-output-complete-obj` carries a `foreign_stubs`-shaped C file
|
||
through without special handling.
|
||
- **No symbol collides** (`symbols.sh`). Flan's own C — `flan_rt.c`, `flan_dev.c`, `flan_agent.c`, `dynload_stubs.c` —
|
||
defines 211 symbols; `libasmrun.a` defines 1,379; the intersection is empty, and the four Flan files do not collide
|
||
with each other either. Worth checking rather than assuming: those four are compiled into *two different processes*
|
||
today, and merging puts them in one link for the first time.
|
||
|
||
### Question 2 — threads, and who owns the main loop
|
||
|
||
**OCaml 5.2 is confirmed multicore**, and `Domain.recommended_domain_count ()` reports 16 here. A spawned domain does
|
||
real work in parallel with the main thread. The objection that would have been fatal is gone.
|
||
|
||
The macOS shape works, and it was tested as the thing that matters rather than as "can OCaml use threads":
|
||
|
||
- `caml_startup` can be called from a **C-created, non-main pthread**, while `main()` goes on to a loop it does not
|
||
leave. `harness4.c` runs a 298-frame mock game loop on the main thread that never once enters OCaml.
|
||
- **A second C thread — one the runtime never created, which is exactly the agent's listener — can call into OCaml**
|
||
after `caml_c_thread_register()`, bracketed by `caml_acquire_runtime_system`/`caml_release_runtime_system`. It
|
||
compiled the same program successfully from that thread. This is the specific capability the merged design needs
|
||
from `flan_agent.c`, and it exists.
|
||
|
||
The spike could not test macOS. Nothing here is macOS-specific — the inversion is a portable pthread arrangement — but
|
||
it is a Linux measurement.
|
||
|
||
### Question 3 — signals
|
||
|
||
**On Linux/amd64, OCaml 5.2 installs no signal handlers at all.** Not SIGSEGV, not SIGINT, not SIGFPE, not SIGPIPE,
|
||
nothing. The expected conflict does not exist.
|
||
|
||
`sig.sh` sweeps fifteen signals from inside the runtime at four moments — before `caml_startup`, at module init, from
|
||
inside a spawned domain, and after `Domain.join` — and every one is `SIG_DFL`. The reading has to be taken from inside
|
||
OCaml rather than from C after `caml_startup` returns, because OCaml 5 starts domains later; the first pass got this
|
||
wrong and read `SIG_DFL` for the wrong reason. A plain `ocamlopt` executable was built as a control and behaves
|
||
identically, so embedding changes nothing about signals.
|
||
|
||
The reason is structural, not incidental: OCaml 5 detects stack overflow with an explicit stack-limit check and calls
|
||
`caml_raise_stack_overflow` directly, rather than with a guard page and a SIGSEGV handler. `nm` on `libasmrun.a` shows
|
||
`sigaltstack` referenced but no SIGSEGV handler defined, which matches.
|
||
|
||
So **the break loop can take `SIGSEGV` outright, and it does not have to install first or last** — order does not
|
||
matter when there is nothing to displace. Measured directly: with the break loop's handler installed, deep OCaml
|
||
recursion still raises `Stack_overflow` normally, and a genuine fault at `0x10` reaches the break loop's handler with
|
||
`si_addr` correct. Chaining was implemented and compared; it is unnecessary here, but `harness5b.c` keeps it, because
|
||
it is what the merged build should do on any platform where the sweep comes back non-empty.
|
||
|
||
**The caveat, and it is the one thing in this report to re-check rather than trust:** this is `x86_64-pc-linux-gnu`
|
||
only. macOS/arm64 OCaml 5 was not testable here, and item 11 raises signals in the same breath as macOS. Re-run
|
||
`sig.sh` there before relying on it. `flan_agent.c` needs nothing from this either way — it sends with `MSG_NOSIGNAL`
|
||
throughout rather than depending on a SIGPIPE disposition.
|
||
|
||
### Question 4 — the GC and raw memory
|
||
|
||
Confirmed, and the confirmation is narrow on purpose. An 8 MiB arena was filled with a checkable pattern and 64 raw
|
||
interior pointers taken into it; OCaml then allocated 26.4 million words, took 7 major collections and a full
|
||
`Gc.compact ()`. Afterwards: the arena base is unmoved, **0 of 8,388,608 bytes altered**, 0 of 64 interior pointers
|
||
invalidated.
|
||
|
||
What that proves is that the collector traces its own roots and foreign memory is invisible to it. Arenas, `Vec`s and
|
||
`Map`s are safe because OCaml never learns they exist.
|
||
|
||
**What would break the assumption**, stated so it is not rediscovered the hard way:
|
||
|
||
1. Storing an OCaml `value` in Flan memory — an arena, a `Vec`, a global — across any allocation. The collector will
|
||
move the block and will not update that word, because it is not a root. `caml_register_global_root` (or the
|
||
generational one) is the only way that is legal, and it is a rule the merged design has to hold: **the boundary
|
||
passes pointers and scalars, never `value`s into Flan storage** — the same rule `dynload_stubs.c` already states
|
||
for its own reason.
|
||
2. A `value` held in a C local across a call that allocates, without `CAMLparam`/`CAMLlocal`. Ordinary OCaml-FFI
|
||
discipline; it applies to every stub the merge adds.
|
||
3. Long C work on the compiler thread without releasing the runtime system — which corrupts nothing but stalls
|
||
whichever domains want a stop-the-world. `llc` and `ld` are `exec`s and would want `caml_release_runtime_system`
|
||
around them.
|
||
|
||
### Question 5 — what it costs
|
||
|
||
| | bytes |
|
||
|---|---|
|
||
| `edn.flan`, release build | 71,824 |
|
||
| `edn.flan`, dev build, as built today | 114,296 |
|
||
| the same dev build with the whole compiler linked in | 4,257,624 |
|
||
| **what the compiler adds** | **~4.14 MB** |
|
||
|
||
**Startup: `caml_startup` takes 0.58–0.72 ms** across six runs — the runtime coming up and every module initialiser in
|
||
the compiler running. Measured with `clock_gettime` around the call itself, not `time(1)` on the process, because exec
|
||
and dynamic linking are paid today anyway.
|
||
|
||
The size reads as 37x, and that framing is misleading. **A dev session today runs two binaries, and the daemon alone is
|
||
4,815,368 bytes.** The merged dev build is *smaller than today's compiler process by itself*, and there is one of it
|
||
instead of two. Sub-millisecond startup and ~4 MB is not a cost worth designing around.
|
||
|
||
One more number, recorded because item 13's step 3 will want it and for no other reason: **a full in-process compile of
|
||
`edn.flan` — read, parse, load, typecheck, emit — is 12.1–12.5 ms**, warm and cold alike, with `llc` and `ld` excluded
|
||
because they are separate processes. That is where the remaining cost sits once transport is gone. It argues for
|
||
nothing; item 13 says the backend is decided at step 4 on a measurement taken at step 3, and this is not that
|
||
measurement.
|
||
|
||
### What this does not answer
|
||
|
||
- **The agent's handlers are a port, not a recompile.** `harness4.c` proves the *pattern* — a C-created listener
|
||
thread can register with the runtime and call OCaml. It does not port `flan_agent.c`'s handlers, which today answer
|
||
requests out of the program's own memory and would instead be calling into the compiler.
|
||
- **Crash isolation is gone by construction.** Not a finding; item 11 already accepts it knowingly. A bad pointer
|
||
through the FFI takes the session, and conditions still catch everything the *language* signals. Noted only so this
|
||
entry stands alone.
|
||
- **macOS, for signals and for the main-thread inversion.** See above.
|
||
- **The backend.** Untouched deliberately.
|
||
|
||
### The order the real work goes in
|
||
|
||
1. **Make the `-output-complete-obj` step a dune rule**, producing the compiler-as-object that a dev build links. This
|
||
is the only build-system work, and `lib/dune`/`bin/dune` did not need changing to prove it.
|
||
2. **Land the `dynload_stubs.c` branch** (`9e0ae3a`, currently reverted). The merged build needs the same
|
||
pointer-and-scalar boundary, and it is already written.
|
||
3. **Invert the startup**: the Flan program keeps `main()`, and `flan_agent_start` also brings up the OCaml runtime on
|
||
its side thread. `merged_main.c` is the sketch.
|
||
4. **Port the agent's handlers** from answering out of the program's memory to calling the compiler directly — this is
|
||
where the socket, the wire protocol, the 4K result cap, the seqlock, the snapshot copying, the generation stamping
|
||
and the render-thunk-per-inspection all get deleted. It is the bulk of the work and the whole of the prize.
|
||
5. **Keep `llc` + `ld` + `dlopen` exactly as they are.** Item 13's third option. Nothing here argues against it.
|
||
6. **Measure what is left.** Then, and only then, item 13 step 4.
|