From 3a3674efb795ecf7ca2bd9ff95268f10ef708de4 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:04:10 +0700 Subject: [PATCH] A gensym is never the same name twice in one compiler process, however many macro modules it loads --- lib/macro.ml | 18 +++++++++++++++++- lib/prelude.ml | 16 ++++++++-------- runtime/flan_rt.c | 8 ++++++++ test/programs/macro-gensym-rounds.flan | 24 ++++++++++++++++++++++++ test/test_acceptance.ml | 5 +++++ 5 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 test/programs/macro-gensym-rounds.flan diff --git a/lib/macro.ml b/lib/macro.ml index 177b4c34..b31ed83a 100644 --- a/lib/macro.ml +++ b/lib/macro.ml @@ -380,12 +380,28 @@ let dir_of (l : loaded) (loc : Loc.t) = always has a signature, and the one thing that could put a name in [fns] without one is the two lists coming apart — in which case expanding unchecked is the wrong half to lose. *) +(* The gensym counter, process-wide. Every module links its own runtime and + so its own [flan_gensym_n], and a build loads several — one per round when + a macro calls a macro, then the one the program is expanded with, and a + session loads one per expansion. A counter that restarted in each would + hand a later module the name an earlier one had already baked into a + macro's code. So the count lives here and is written into the module + before every call and read back after, whether the call returns or + raises. *) +let gensym_n = ref 0L + +let with_gensym (l : loaded) f = + let cell = Dynload.dl_sym l.handle "flan_gensym_n" in + Dynload.poke_i64 cell 0 !gensym_n; + Fun.protect ~finally:(fun () -> gensym_n := Dynload.peek_i64 cell 0) f + let checked_call (l : loaded) n ~loc (args : Form.t list) : Form.t = (match List.assoc_opt n l.sigs with | Some sg -> Expand.check_call ~name:n ~loc sg args | None -> ()); dir_of l loc; - Expand.call ~loc:(Loc.from_macro n loc) (List.assoc n l.fns) args + with_gensym l (fun () -> + Expand.call ~loc:(Loc.from_macro n loc) (List.assoc n l.fns) args) let fuel = 200 diff --git a/lib/prelude.ml b/lib/prelude.ml index f8deb3ff..8f6cd1e2 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -2137,19 +2137,19 @@ let source = {flan| ;; explicit gensym is the settled decision (plan.org, open decision 2); this is ;; the escape hatch that makes it liveable. ;; -;; The counter lives in the loaded module rather than in the compiler, which is -;; the one place this departs from the sketch. A module is dlopened once -;; per compiler process and every macro in a program shares it, so the counter -;; is process-wide in practice; a second module would restart it, and the day -;; there is one, the fix is to seed this from the module's index. -(defonce gensym-n i64 0) +;; The counter is C data in the runtime, flan_gensym_n, because a build loads +;; more than one macro module — one per round when macros call macros, and +;; another for every expansion in a session — and each links its own copy of +;; the runtime. lib/macro.ml keeps the count across them: it writes it into +;; the module before every macro call and reads it back after, so no two +;; modules in one compiler process draw the same name. +(declare gensym-next [] i64 "flan_gensym_next") (defn gensym [] Form - (set gensym-n (+ gensym-n 1)) (let [v (vec-new u8)] (push v 126) ; ~ (push v 103) ; g - (let [d (i64->bytes gensym-n)] + (let [d (i64->bytes (gensym-next))] (dotimes [i (length d)] (push v (at d i)))) (Form.Sym {.s (string (slice v))}))) diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 5c91b6c6..7862ee28 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -3193,6 +3193,14 @@ const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) { char flan_macro_dir[FLAN_PATH_MAX] = { 0 }; int64_t flan_macro_dir_n = 0; +/* The prelude's gensym counter. C data rather than a Flan global for the same + * reason as the two above: lib/macro.ml writes it into a module before every + * macro call and reads it back after, which is what keeps it counting across + * every module a compiler process loads rather than restarting in each. */ +int64_t flan_gensym_n = 0; + +int64_t flan_gensym_next(void) { return ++flan_gensym_n; } + /* The bytes are the caller's to read and nobody's to free: an expansion is * bounded by the size of the program being compiled, which is exactly the * budget lib/dynload.ml's `owned` note already spends on a macro's own diff --git a/test/programs/macro-gensym-rounds.flan b/test/programs/macro-gensym-rounds.flan new file mode 100644 index 00000000..74584556 --- /dev/null +++ b/test/programs/macro-gensym-rounds.flan @@ -0,0 +1,24 @@ +;;;; Two gensyms from two macro modules are never the same name. +;;;; +;;;; `outer` calls `baked` in its body, so `baked` is compiled in a round of +;;;; its own and `outer`'s body is expanded against that module before `outer` +;;;; is compiled. The gensym `baked` draws there becomes a literal in +;;;; `outer`'s code; the one `outer` draws itself comes from the later module +;;;; the program is expanded with. `main` comes first so that its expansion is +;;;; that module's first draw. Were the two the same name, the second binding +;;;; would shadow the first and this would print 200. + +(defn main [] i32 + (println (outer 1)) + 0) + +;; Expands to code that builds the symbol this expansion drew. +(defmacro baked [] + (match (gensym) + (Form.Sym s) `(Form.Sym {.s ~(Form.Str {.s s})}) + _ `(form-nil))) + +(defmacro outer [a] + (let [g (baked) + h (gensym)] + `(let [~g ~a ~h 100] (+ ~g ~h)))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 3b5bfcdb..cc32cacc 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -3893,6 +3893,11 @@ level "1" outputs ~dev:true "a macro's parameter list, dev" "programs/macro-params.flan" macro_params_out; + (* A gensym drawn in one round's module and one drawn in the module the + program is expanded with are different names. 200 is the two colliding. *) + outputs "gensym counts across macro modules" + "programs/macro-gensym-rounds.flan" "101\n"; + (* A macro declared in an imported *package*, which is the half the refusal at [a package's macro is not visible unqualified] above leaves out. The program calls six of them qualified and one of its own