From 6260230231a35f1aeb6931cf978eca519cc5ebe8 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 10 Jun 2026 18:16:06 -0400 Subject: [PATCH] core: edn :readers/:default opts; uncaught throws no longer leak the callee's ns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clojure.edn was nearly complete (sets, #uuid/#inst, :eof all landed earlier); the :readers opt was ignored and :default missing. Both work now — the reader stores a tag as a :#name keyword, so the lookup normalizes it to the symbol Clojure keys :readers with; :default gets (tag value); built-in data readers stay the fallback. 8 new edn spec rows. This was the last open item of jolt-0mb (the vendored walk/zip/data/edn battery has been green for a while: 34/33/61/50, all clean). Chasing the probe cascade ('Unable to resolve symbol: edn/...' after one error) found a real evaluator bug: an interpreted fn body runs with current-ns rebound to its DEFINING ns and restores it with a plain trailing call — an UNCAUGHT throw skips every restore on the way out, leaving the ctx stuck in the deepest callee's ns, where alias-qualified lookups then fail (the same cascade previously seen via sci). The repair lives at the TOP-LEVEL boundary (loader/eval-toplevel saves the entry ns and restores it on error before re-raising) — NOT per-call defer/try, which builds a fiber per frame and blew the C stack on deep interpreted recursion (file-seq) when tried first. Regression tests cover the cross-eval leak and that aliases keep resolving. --- src/jolt/clojure/edn.clj | 29 ++++++++++++++++++--------- src/jolt/evaluator.janet | 12 +++++++++++ src/jolt/loader.janet | 14 +++++++++++++ test/integration/namespace-test.janet | 17 ++++++++++++++++ test/spec/namespaces-spec.janet | 11 ++++++++++ test/spec/stage3-io-ns-spec.janet | 13 ++++++++++++ 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/jolt/clojure/edn.clj b/src/jolt/clojure/edn.clj index 5181e99..46566e6 100644 --- a/src/jolt/clojure/edn.clj +++ b/src/jolt/clojure/edn.clj @@ -8,20 +8,31 @@ ;; The reader yields set literals as a FORM ({:jolt/type :jolt/set :value [...]}) ;; rather than a constructed set, so build the actual values, recursing into ;; maps/vectors/lists. (Lists stay lists — EDN never evaluates them as code.) -(defn- edn->value [x] +(defn- edn->value [opts x] (cond ;; Reader FORMS are detected by :jolt/type tag, never by map? — strict map? ;; (correctly) excludes tagged structs, so the old (and (map? x) ...) guard ;; would skip them. - (= :jolt/set (get x :jolt/type)) (set (map edn->value (get x :value))) - ;; EDN built-in tagged elements (#uuid/#inst, plus registered readers): - ;; apply the data reader to the read form (no evaluation involved). + (= :jolt/set (get x :jolt/type)) (set (map (fn [v] (edn->value opts v)) (get x :value))) + ;; Tagged elements: a reader from the :readers opt wins, then the built-in + ;; data readers (#uuid/#inst + registered); an unknown tag falls to the + ;; :default opt fn (called with tag and value, as in Clojure) or throws. (= :jolt/tagged (get x :jolt/type)) - (__read-tagged (get x :tag) (edn->value (get x :form))) + (let [tag (get x :tag) + v (edn->value opts (get x :form)) + ;; the reader stores the tag as a :#name keyword; :readers maps are + ;; keyed by the SYMBOL (Clojure's shape) — normalize for lookup + tag-sym (let [n (name tag)] + (symbol (if (= "#" (subs n 0 1)) (subs n 1) n))) + custom (get (get opts :readers) tag-sym)] + (cond + custom (custom v) + (get opts :default) ((get opts :default) tag v) + :else (__read-tagged tag v))) (map? x) - (into {} (map (fn [e] [(edn->value (key e)) (edn->value (val e))]) x)) - (vector? x) (mapv edn->value x) - (seq? x) (map edn->value x) + (into {} (map (fn [e] [(edn->value opts (key e)) (edn->value opts (val e))]) x)) + (vector? x) (mapv (fn [v] (edn->value opts v)) x) + (seq? x) (map (fn [v] (edn->value opts v)) x) :else x)) ;; Private helper, NOT named read-string: an unqualified (read-string …) call @@ -30,7 +41,7 @@ (defn- read-edn [opts s] (if (or (nil? s) (cstr/blank? s)) (get opts :eof nil) - (edn->value (clojure.core/read-string s)))) + (edn->value opts (clojure.core/read-string s)))) (defn read-string "Reads one object from the string s. Returns the :eof option value (default diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index db0150e..c92f196 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -1341,6 +1341,10 @@ # Use defining namespace for symbol resolution (def saved-ns (ctx-current-ns ctx)) (ctx-set-current-ns ctx defining-ns) + # Plain trailing restore (NOT defer/try — those build a fiber per + # call and blow the C stack on deep interpreted recursion). An + # unwinding throw is repaired once at the TOP-LEVEL boundary + # (loader/eval-toplevel restores the ns on error). (var result nil) (each bf body (set result (eval-form ctx new-bindings bf))) @@ -1417,6 +1421,10 @@ # Use defining namespace for symbol resolution (def saved-ns (ctx-current-ns ctx)) (ctx-set-current-ns ctx defining-ns) + # Plain trailing restore (NOT defer/try — those build a fiber per + # call and blow the C stack on deep interpreted recursion). An + # unwinding throw is repaired once at the TOP-LEVEL boundary + # (loader/eval-toplevel restores the ns on error). (var result nil) (each body-form body (set result (eval-form ctx fn-bindings body-form))) @@ -1456,6 +1464,10 @@ # Use defining namespace for symbol resolution (def saved-ns (ctx-current-ns ctx)) (ctx-set-current-ns ctx defining-ns) + # Plain trailing restore (NOT defer/try — those build a fiber per + # call and blow the C stack on deep interpreted recursion). An + # unwinding throw is repaired once at the TOP-LEVEL boundary + # (loader/eval-toplevel restores the ns on error). (var result nil) (each body-form body (set result (eval-form ctx fn-bindings body-form))) diff --git a/src/jolt/loader.janet b/src/jolt/loader.janet index 7df16ff..05486a4 100644 --- a/src/jolt/loader.janet +++ b/src/jolt/loader.janet @@ -3,6 +3,7 @@ # Supports in-memory bytecode caching when :compile? is enabled. (use ./reader) +(use ./types) (use ./evaluator) (import ./backend :as backend) @@ -30,6 +31,14 @@ interpreter for forms it can't compile. Only the compile step is guarded — runtime errors in compiled code propagate (no double-eval, no hidden errors)." [ctx form] + # Repair point for the interpreted-fn ns swap: a body runs with current-ns + # rebound to its defining ns and restores it on normal return; an UNWINDING + # throw skips those restores (they're plain trailing calls — defer/try per + # call would cost a fiber per frame and blow the C stack on deep recursion). + # So save here, and on error put the entry ns back before re-raising — the + # ctx never leaks a callee's ns across top-level forms. + (def entry-ns (ctx-current-ns ctx)) + (defn- run [] (defn try-compile [] (backend/compile-and-eval ctx form)) (if (get (ctx :env) :compile?) (if (array? form) @@ -43,6 +52,11 @@ (try-compile) (eval-form ctx @{} form))) (eval-form ctx @{} form))) + (def res (protect (run))) + (if (res 0) + (res 1) + (do (ctx-set-current-ns ctx entry-ns) + (error (res 1))))) (defn load-ns "Load a Clojure namespace from a .clj file. Per-form routing (compile-or- diff --git a/test/integration/namespace-test.janet b/test/integration/namespace-test.janet index 755128b..e3d3417 100644 --- a/test/integration/namespace-test.janet +++ b/test/integration/namespace-test.janet @@ -96,3 +96,20 @@ (print " passed") (print "\nAll namespace tests passed!") + +# An UNCAUGHT throw inside an interpreted fn body must restore the caller's +# current-ns (the body runs with current-ns rebound to the defining ns; the +# restore is a defer now). Pre-fix, the ctx was left stuck in the defining ns +# and every later alias-qualified lookup failed — the sci-bootstrap and +# clojure.edn "Unable to resolve alias/..." cascades. +(print "ns restored after uncaught throw...") +(let [ctx (fresh-ctx)] + (api/eval-string ctx "(in-ns (quote otherns))") + (api/eval-string ctx "(clojure.core/refer-clojure)") + (api/eval-string ctx "(defn boom [] (throw (ex-info \"x\" {})))") + (api/eval-string ctx "(in-ns (quote user))") + (assert (not ((protect (api/eval-string ctx "(otherns/boom)")) 0)) "boom throws") + (assert (= "user" (api/eval-string ctx "(str *ns*)")) "*ns* restored after uncaught throw") + (api/eval-string ctx "(require (quote [clojure.string :as s9]))") + (assert (= "A" (api/eval-string ctx "(s9/upper-case \"a\")")) "aliases still resolve")) +(print " ok") diff --git a/test/spec/namespaces-spec.janet b/test/spec/namespaces-spec.janet index adf8437..4ee3342 100644 --- a/test/spec/namespaces-spec.janet +++ b/test/spec/namespaces-spec.janet @@ -42,3 +42,14 @@ "(do (require (quote clojure.string)) (alias (quote st2) (quote clojure.string)) (ns-unalias (quote user) (quote st2)) (nil? (get (ns-aliases (quote user)) (quote st2))))"] ["ns-publics has var" "true" "(do (def npv 1) (some? (get (ns-publics (quote user)) (quote npv))))"] ["newline returns nil" "nil" "(newline)"]) + +# A throw inside an interpreted fn body (or macro expander) must restore the +# caller's current-ns: the body runs with current-ns rebound to the fn's +# DEFINING ns, and an unwind that skipped the restore left the ctx stuck +# there — every later alias-qualified lookup in the REPL ns then failed +# ("Unable to resolve symbol: alias/...", seen via sci + clojure.edn). +(defspec "namespaces / error inside a fn must not leak its defining ns" + ["alias survives a throwing stdlib call" "\"A\"" + "(do (require (quote [clojure.string :as s9])) (try (s9/join nil nil nil) (catch Exception e nil)) (s9/upper-case \"a\"))"] + ["*ns* restored after throw" "\"user\"" + "(do (require (quote [clojure.walk :as w9])) (try (w9/postwalk nil nil nil) (catch Exception e nil)) (str *ns*))"]) diff --git a/test/spec/stage3-io-ns-spec.janet b/test/spec/stage3-io-ns-spec.janet index c22c2be..386ce64 100644 --- a/test/spec/stage3-io-ns-spec.janet +++ b/test/spec/stage3-io-ns-spec.janet @@ -37,3 +37,16 @@ ["load-string evals all" "3" "(load-string \"(def lsv 1) (+ lsv 2)\")"] ["eval as value" "[2 3]" "(mapv eval [(quote (+ 1 1)) (quote (+ 1 2))])"] ["eval special still works" "3" "(eval (quote (+ 1 2)))"]) + +# clojure.edn is complete (jolt-b7y / jolt-0mb): sets, #uuid/#inst, :eof, +# and the :readers / :default opts (tag normalized from the reader's :#name +# keyword to the symbol Clojure keys :readers with). +(defspec "clojure.edn / opts" + ["set literal" "#{1 2}" "(do (require (quote [clojure.edn :as e0])) (e0/read-string \"#{1 2}\"))"] + ["uuid tag" "true" "(do (require (quote [clojure.edn :as e0])) (uuid? (e0/read-string \"#uuid \\\"550e8400-e29b-41d4-a716-446655440000\\\"\")))"] + ["inst tag" "true" "(do (require (quote [clojure.edn :as e0])) (inst? (e0/read-string \"#inst \\\"2020-01-01T00:00:00Z\\\"\")))"] + [":eof on empty" ":end" "(do (require (quote [clojure.edn :as e0])) (e0/read-string {:eof :end} \"\"))"] + [":readers custom tag" "[:custom 5]" "(do (require (quote [clojure.edn :as e0])) (e0/read-string {:readers {(quote custom) (fn [v] [:custom v])}} \"#custom 5\"))"] + [":readers nested" "[6 8]" "(do (require (quote [clojure.edn :as e0])) (e0/read-string {:readers {(quote w) (fn [v] (* 2 v))}} \"[#w 3 #w 4]\"))"] + [":default fn" "[:dflt 7]" "(do (require (quote [clojure.edn :as e0])) (e0/read-string {:default (fn [t v] [:dflt v])} \"#unknown 7\"))"] + ["unknown tag throws" :throws "(do (require (quote [clojure.edn :as e0])) (e0/read-string \"#nope 1\"))"])