388 lines
28 KiB
Markdown
388 lines
28 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
|
||
|
||
Two things got conflated here and they should be separated.
|
||
|
||
**What the inspector already does.** Most of what was asked for is built. `flan-inspect` opens its own buffer, lays a
|
||
value's fields one per line, `RET` walks into one, `l` comes back, `g` re-reads. The renderer bounds its walk at depth 4
|
||
and span 8, and entering a field renders *that field* from depth 0 — so the elision moves with you rather than
|
||
truncating permanently. It is CIDER's inspector adapted, and the file says what the adaptation changed.
|
||
|
||
**What is actually missing** is detail on the leaves: a number shows in decimal only, with no hex and no binary, and a
|
||
pointer does not show its address. Purely additive, small, and worth doing.
|
||
|
||
**The real problem, and it is sharper than "a bug".** The locals listing renders from each frame's own slot addresses,
|
||
so it is frame-accurate. The inspector is built on a stack of **expressions** — going into a field means sending a
|
||
different expression (`(.pos b)` where the last was `b`), and `l` works by popping back to the previous one. That design
|
||
is forced: a Flan value has no header, the thunk that rendered it is `dlclose`d as soon as it returns, and there is no
|
||
heap to retain anything in, so nothing can be held server-side the way CIDER holds a JVM object.
|
||
|
||
The consequence is that `i` evaluates a name wherever the evaluator stands, **not in the frame being looked at**. On the
|
||
innermost frame that happens to be right. On any other it may resolve to a global, to a different binding, or fail —
|
||
with nothing saying so.
|
||
|
||
**An earlier suggestion in this conversation — "root the inspector at the slot's address" — does not work**, and the
|
||
reason is worth keeping: an address is not an expression, so the first `RET` has nothing to build the next expression
|
||
from and navigation dies at step one. Recorded because it is the obvious fix and it is wrong.
|
||
|
||
So the options are genuinely three, and none is free:
|
||
|
||
1. **Teach the program to evaluate an expression relative to a frame.** The most useful and the most work: the frame's
|
||
slots would have to be in scope for a compiled thunk, which means the daemon building a thunk whose free names bind
|
||
to that frame's addresses. It would also fix `C-x C-e` while stopped, which has the same blindness.
|
||
2. **Give the inspector a second rooting mode** — an address root that can still walk, by carrying a type alongside the
|
||
address and stepping to a field's address rather than to a sub-expression. Navigation then works, but the two modes
|
||
have different capabilities and `l` has to cross between them.
|
||
3. **Refuse `i` outside the innermost frame**, honestly and by name. Cheapest, and it gives up the feature exactly where
|
||
it is most wanted, since the innermost frame is the one already fully visible.
|
||
|
||
## 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.
|