core: edn :readers/:default opts; uncaught throws no longer leak the callee's ns

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.
This commit is contained in:
Yogthos 2026-06-10 18:16:06 -04:00
parent fef99db6d8
commit 6260230231
6 changed files with 87 additions and 9 deletions

View file

@ -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")

View file

@ -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*))"])

View file

@ -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\"))"])