From 512b25dd8d581506a1aabd86f4fafb72ee5fffe9 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Mon, 12 Oct 2026 05:31:45 -0400 Subject: [PATCH] Send eval errors in JVM nREPL's shape On a jolt whose evaluate returns :err-msg/:ex-class/:root-ex-class, the session worker now sends err in clojure.main/err->msg form, ex and root-ex as class strings with eval-error, and done on its own message, as JVM nREPL does. The worker's own catch path renders through jolt.nrepl/err->msg when it exists. Older jolt keeps the message-only shape. --- src/nrepl/middleware/session.clj | 33 +++++++++++++++++++------- test/nrepl/middleware/session_test.clj | 30 +++++++++++++++++++++++ test/nrepl/test_helpers.clj | 6 +++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/nrepl/middleware/session.clj b/src/nrepl/middleware/session.clj index 0e800a9..16058a1 100644 --- a/src/nrepl/middleware/session.clj +++ b/src/nrepl/middleware/session.clj @@ -41,18 +41,31 @@ ns-atom (:ns session) ;; the actual evaluation runs interruptibly: interrupt! on `token` aborts ;; it (throws {:jolt/interrupted true}), which the worker catches below. - {:keys [value out ns err]} + {:keys [value out ns err ex err-msg ex-class root-ex-class]} (jolt.host/run-interruptible token (fn [] (server/evaluate code (or ns-str @ns-atom))))] (reset! ns-atom ns) (when (seq out) (reply {"out" out})) - (if err - (let [e (last-eval-exception err)] - (reply {"err" (str err "\n")}) + (cond + (nil? err) + (reply {"value" value "ns" ns "status" ["done"]}) + + ;; a jolt whose evaluate renders the error the way JVM nREPL sends it: + ;; err in clojure.main/err->msg form, ex/root-ex as class strings with + ;; eval-error, then done on its own message + err-msg + (let [e (or ex (last-eval-exception err))] + (reply {"err" err-msg}) ;; the throwable rides the eval-error response only, so a hook runs ;; once per error; wrap-caught dissocs it before the wire + (reply (cond-> {"ex" ex-class "root-ex" root-ex-class "status" ["eval-error"]} + (and caught? e) (assoc caught/throwable-key e))) + (reply {"status" ["done"]})) + + :else + (let [e (last-eval-exception err)] + (reply {"err" (str err "\n")}) (reply (cond-> {"ex" (str err) "status" ["eval-error" "done"]} - (and caught? e) (assoc caught/throwable-key e)))) - (reply {"value" value "ns" ns "status" ["done"]})))) + (and caught? e) (assoc caught/throwable-key e))))))) (defn- spawn-worker [session] ;; A daemon, as nREPL's session threads are on the JVM: the worker waits on @@ -71,8 +84,12 @@ (catch :default e (if (:jolt/interrupted (ex-data e)) (reply {"status" ["interrupted" "done"]}) - (do (reply {"err" (str (server/err-msg e) "\n")}) - (reply {"status" ["eval-error" "done"]})))) + (if-let [err->msg (resolve 'jolt.nrepl/err->msg)] + (do (reply {"err" (err->msg e)}) + (reply {"ex" (str (class e)) "status" ["eval-error"]}) + (reply {"status" ["done"]})) + (do (reply {"err" (str (server/err-msg e) "\n")}) + (reply {"status" ["eval-error" "done"]}))))) (finally (reset! (:current session) nil))) (recur)))))) (.setDaemon true) diff --git a/test/nrepl/middleware/session_test.clj b/test/nrepl/middleware/session_test.clj index 25da5fb..a2d0134 100644 --- a/test/nrepl/middleware/session_test.clj +++ b/test/nrepl/middleware/session_test.clj @@ -34,3 +34,33 @@ (is (contains? (set (:sessions (h/combine (h/message t {:op "ls-sessions"})))) s)) (h/message t {:op "close" :session s}) (is (not (contains? (set (:sessions (h/combine (h/message t {:op "ls-sessions"})))) s)))))) + +(deftest eval-error-has-jvm-shape + (when h/jvm-error-shape? + (with-conn t + (let [s (h/new-session t) + rs (h/eval-code t "(/ 1 0)" :session s) + c (h/combine rs)] + (is (= "Execution error (ArithmeticException) at (REPL:1).\nDivide by zero\n" (:err c))) + (is (= "class java.lang.ArithmeticException" (:ex c))) + (is (= "class java.lang.ArithmeticException" (:root-ex c))) + (is (contains? (:status c) "eval-error")) + (is (contains? (:status c) "done")) + (testing "done arrives on its own message, after eval-error, as on the JVM" + (is (= ["done"] (get (last rs) :status)))))))) + +(deftest reader-error-has-no-stale-trace + (when h/jvm-error-shape? + (with-conn t + (let [s (h/new-session t) + c (h/combine (h/eval-code t "(defn broken [x]" :session s))] + (is (re-find #"^Syntax error reading source at \(REPL:1:\d+\)\.\nEOF while reading\n$" (:err c))) + (is (not (re-find #"trace:" (:err c)))))))) + +(deftest lazy-result-output-reaches-the-client + (when h/jvm-error-shape? + (with-conn t + (let [s (h/new-session t) + c (h/combine (h/eval-code t "(map (fn [x] (println \"side\" x) x) [1 2])" :session s))] + (is (= "side 1\nside 2\n" (:out c))) + (is (= ["(1 2)"] (:value c))))))) diff --git a/test/nrepl/test_helpers.clj b/test/nrepl/test_helpers.clj index 02dceec..69e16ae 100644 --- a/test/nrepl/test_helpers.clj +++ b/test/nrepl/test_helpers.clj @@ -88,3 +88,9 @@ (def combine nrepl/combine-responses) (def values nrepl/response-values) (def send-raw transport/send) + +(def jvm-error-shape? + "True when jolt.nrepl/evaluate returns the JVM nREPL error shape (:err-msg, + :ex-class, :root-ex-class) — jolt with `jolt.nrepl/err->msg`. On an older + jolt the session middleware falls back to the message-only shape." + (boolean (resolve 'jolt.nrepl/err->msg)))