Trace by default in REPL-driven development

A repl or nREPL session now turns tail-frame tracing on, so an uncaught error in
evaluated/reloaded code shows a tail-frame backtrace with no JOLT_TRACE set. The
REPL and nREPL catch errors themselves rather than going through the uncaught
reporter, so they now print the history backtrace via a new jolt.host/backtrace-
string (history-only — the live continuation in a REPL is just REPL machinery).

Because the recording is baked in at compile time, only code compiled while a
session is live is traced; reload a namespace to trace already-loaded code.
JOLT_TRACE=1 still forces it on for a whole run (a plain -M:run traces its own
load); JOLT_TRACE=0 forces it off even in a session.

No seed change — jolt.main/jolt.nrepl are runtime-loaded and compile-eval.ss /
source-registry.ss are host files.
This commit is contained in:
Yogthos 2026-07-04 15:23:17 -04:00
parent a3e2365217
commit 7167af4830
6 changed files with 89 additions and 23 deletions

View file

@ -190,11 +190,21 @@ binary the frames map to `ns/name (file:line)`; on the runtime eval path they ar
the surviving fn names. Tail-call optimization erases tail-called frames, so the the surviving fn names. Tail-call optimization erases tail-called frames, so the
default trace shows only the non-tail spine. default trace shows only the non-tail spine.
Set `JOLT_TRACE=1` to opt into a fuller **tail-frame history**. Each compiled fn A fuller **tail-frame history** recovers the frames TCO erases: each compiled fn
then records itself on entry into a bounded ring-of-rings buffer, so the trace records itself on entry into a bounded ring-of-rings buffer, so the trace shows
recovers TCO-elided frames (including the immediate error site) while a tight tail TCO-elided frames (including the immediate error site) while a tight tail loop
loop stays bounded and its non-tail caller context is preserved. It costs a small stays bounded and its non-tail caller context is preserved.
per-call overhead, so it is off by default and never emitted into a built binary.
It is **on by default in REPL-driven development** — a `repl` or nREPL session
turns it on, so an error in code you evaluate or reload shows a tail-frame trace
with no setup. Because the recording is baked in at compile time, only code
compiled while a session is live is traced; reload a namespace to trace code that
was already loaded (e.g. an app's initial `-M:run` load before its nREPL started).
Elsewhere it is off (a small per-call cost, and never emitted into a `jolt build`
binary). Override with the environment: `JOLT_TRACE=1` forces it on for a whole
run — including a plain `-M:run`, so the app's own load is traced — and
`JOLT_TRACE=0` forces it off, even in a REPL/nREPL session.
## Conformance ## Conformance

View file

@ -110,13 +110,24 @@
;; older seed during the first re-mint pass. ;; older seed during the first re-mint pass.
(let ((scv (var-deref "jolt.backend-scheme" "set-var-cache!"))) (let ((scv (var-deref "jolt.backend-scheme" "set-var-cache!")))
(when (procedure? scv) (scv #t))) (when (procedure? scv) (scv #t)))
;; JOLT_TRACE opts into tail-frame history: the emitter prepends a frame-recording ;; Tail-frame history. Turning it on makes the emitter add a per-fn history push to
;; push to every runtime-compiled fn, and the ring buffer is allocated for this ;; every fn compiled AFTERWARD, and allocates this thread's ring. Suppressed when
;; thread. Off by default, so a normal run emits and pays exactly as before. ;; JOLT_TRACE is explicitly falsey, so JOLT_TRACE=0 disables it even in dev mode.
(when (getenv "JOLT_TRACE") (define (jolt-enable-trace!)
(let ((e (getenv "JOLT_TRACE")))
(unless (and e (member e '("0" "false" "no")))
(let ((stf (var-deref "jolt.backend-scheme" "set-trace-frames!"))) (let ((stf (var-deref "jolt.backend-scheme" "set-trace-frames!")))
(when (procedure? stf) (stf #t))) (when (procedure? stf) (stf #t)))
(jolt-trace-enable!)) (jolt-trace-enable!))))
;; Exposed so the REPL / nREPL entrypoints (jolt.main, jolt.nrepl) can turn tracing
;; on for REPL-driven development without the user setting JOLT_TRACE. Because the
;; push is baked in at compile time, only code compiled after this call is traced —
;; which is exactly the code you eval / reload in a live session.
(def-var! "jolt.host" "enable-trace!" jolt-enable-trace!)
;; Explicit opt-in for a whole run (JOLT_TRACE=1): enable at load, BEFORE any app
;; namespace is compiled, so a plain `-M:run` traces the app's own code too.
(let ((e (getenv "JOLT_TRACE")))
(when (and e (not (member e '("0" "false" "no" "")))) (jolt-enable-trace!)))
;; (with-meta sym m) -> sym, else x — an (ns ^:no-doc name …) yields the name with ;; (with-meta sym m) -> sym, else x — an (ns ^:no-doc name …) yields the name with
;; reader metadata as a with-meta form; strip it to read the bare ns symbol. ;; reader metadata as a with-meta form; strip it to read the bare ns symbol.

View file

@ -224,5 +224,25 @@ else
fails=$((fails + 1)) fails=$((fails + 1))
fi fi
# REPL-driven development traces by default: an error in an evaluated form shows a
# tail-frame backtrace with no JOLT_TRACE set. rb tail-calls ra tail-calls +, all
# TCO-elided from the continuation — only the history recovers them.
repl_err="$(printf '(defn ra [x] (+ x 1))\n(defn rb [x] (ra x))\n(rb :nan)\n:exit\n' | bin/joltc repl 2>&1)"
if printf '%s' "$repl_err" | grep -q ' trace:' && printf '%s' "$repl_err" | grep -q 'rb'; then
pass=$((pass + 1))
else
echo " FAIL: a REPL error should show a tail-frame trace by default"
printf '%s\n' "$repl_err" | sed 's/^/ | /'
fails=$((fails + 1))
fi
# JOLT_TRACE=0 opts out — no trace in the REPL.
repl_off="$(printf '(defn ra [x] (+ x 1))\n(defn rb [x] (ra x))\n(rb :nan)\n:exit\n' | JOLT_TRACE=0 bin/joltc repl 2>&1)"
if printf '%s' "$repl_off" | grep -q ' trace:'; then
echo " FAIL: JOLT_TRACE=0 should suppress the REPL trace"
fails=$((fails + 1))
else
pass=$((pass + 1))
fi
echo "cli smoke: $pass passed, $fails failed" echo "cli smoke: $pass passed, $fails failed"
[ "$fails" -eq 0 ] [ "$fails" -eq 0 ]

View file

@ -145,10 +145,11 @@
;; TCO-truncated non-tail spine. ;; TCO-truncated non-tail spine.
;; Each frame maps to "ns/name (file:line)" when registered, else its bare name. ;; Each frame maps to "ns/name (file:line)" when registered, else its bare name.
;; #f when neither source yields a frame (the caller then prints just the location). ;; #f when neither source yields a frame (the caller then prints just the location).
(define (jolt-backtrace-string v) ;; The tail-frame history ring rendered as a backtrace, or #f when tracing is off /
;; empty. A mapped frame is kept; else drop plumbing (same rule as the continuation
;; path) so the two sources read consistently.
(define (jolt-history-backtrace)
(let* ((hist (jolt-trace-snapshot)) (let* ((hist (jolt-trace-snapshot))
;; a mapped frame is always kept; else drop plumbing (same rule as the
;; continuation path) so the two backtrace sources read consistently
(recs (let loop ((ns hist) (acc '())) (recs (let loop ((ns hist) (acc '()))
(if (null? ns) (if (null? ns)
(reverse acc) (reverse acc)
@ -156,12 +157,23 @@
(loop (cdr ns) (loop (cdr ns)
(if (or src (not (srcreg-plumbing-name? nm))) (if (or src (not (srcreg-plumbing-name? nm)))
(cons (cons nm src) acc) acc))))))) (cons (cons nm src) acc) acc)))))))
(if (pair? recs) (and (pair? recs) (jolt-render-recs recs))))
(jolt-render-recs recs)
(define (jolt-backtrace-string v)
(or (jolt-history-backtrace)
(let ((k (jolt-error-continuation v))) (let ((k (jolt-error-continuation v)))
(and k (and k
(let ((recs (jolt-frame-records k))) (let ((recs (jolt-frame-records k)))
(and (pair? recs) (jolt-render-recs recs)))))))) (and (pair? recs) (jolt-render-recs recs)))))))
;; Exposed for the REPL / nREPL error paths, which catch errors themselves instead
;; of going through the uncaught reporter. Returns the " trace:\n<frames>" block
;; from the tail-frame HISTORY only — the live continuation in a REPL is just the
;; REPL's own machinery — or nil when tracing is off (so a caller can when-let).
(def-var! "jolt.host" "backtrace-string"
(lambda ()
(let ((bt (jolt-history-backtrace)))
(if bt (string-append " trace:\n" bt) jolt-nil))))
;; Render an uncaught jolt throw (any value, not just a Chez condition) to a port: ;; Render an uncaught jolt throw (any value, not just a Chez condition) to a port:
;; an ex-info shows its message + ex-data (+ a host cause); anything else is ;; an ex-info shows its message + ex-data (+ a host cause); anything else is

View file

@ -147,6 +147,9 @@
;; loaded — same context a run gets, so (require '[some.lib]) works in the REPL. ;; loaded — same context a run gets, so (require '[some.lib]) works in the REPL.
(try (apply-project! (deps/resolve-project (project-dir))) (try (apply-project! (deps/resolve-project (project-dir)))
(catch :default _ nil)) (catch :default _ nil))
;; REPL-driven development: trace by default so an uncaught error in evaluated
;; code shows a tail-frame backtrace, no JOLT_TRACE needed (JOLT_TRACE=0 opts out).
(jolt.host/enable-trace!)
(println (str ";; jolt " (version) " repl — :repl/quit or ^D to exit")) (println (str ";; jolt " (version) " repl — :repl/quit or ^D to exit"))
(loop [] (loop []
(let [form (repl-read-form)] (let [form (repl-read-form)]
@ -160,7 +163,9 @@
(catch :default e (catch :default e
(println "error:" (or (ex-message e) (println "error:" (or (ex-message e)
(try ((resolve 'jolt.host/condition-message) e) (catch :default _ nil)) (try ((resolve 'jolt.host/condition-message) e) (catch :default _ nil))
(pr-str e))))) (pr-str e)))
(when-let [bt (jolt.host/backtrace-string)]
(print bt))))
(recur))))))) (recur)))))))
;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}. ;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}.

View file

@ -188,7 +188,10 @@
(try (when (and ns-str (not (str/blank? ns-str)) (find-ns (symbol ns-str))) (try (when (and ns-str (not (str/blank? ns-str)) (find-ns (symbol ns-str)))
(in-ns (symbol ns-str))) (in-ns (symbol ns-str)))
(reset! result (load-string code)) (reset! result (load-string code))
(catch :default e (reset! err (err-msg e)))))] (catch :default e
(reset! err (str (err-msg e)
(when-let [bt (jolt.host/backtrace-string)]
(str "\n" bt)))))))]
{:value (when (nil? @err) (pr-str @result)) {:value (when (nil? @err) (pr-str @result))
:out out :out out
:ns (str (ns-name *ns*)) :ns (str (ns-name *ns*))
@ -277,6 +280,11 @@
no-op." no-op."
([port] (start port nil)) ([port] (start port nil))
([port middleware] ([port middleware]
;; An nREPL session is REPL-driven development: trace by default so an uncaught
;; error in code evaluated over the connection shows a tail-frame backtrace, with
;; no JOLT_TRACE needed. Covers both `--nrepl-server` and an app that starts its
;; own server under `-M:run` (reload a namespace to trace already-loaded code).
(jolt.host/enable-trace!)
(let [handler (build-handler (resolve-middleware (or middleware []))) (let [handler (build-handler (resolve-middleware (or middleware [])))
fd (listen-socket port) ; throws on bind/listen failure fd (listen-socket port) ; throws on bind/listen failure
stopped (atom false)] stopped (atom false)]