diff --git a/docs/tools-deps.md b/docs/tools-deps.md index fc27c53..cba1912 100644 --- a/docs/tools-deps.md +++ b/docs/tools-deps.md @@ -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 default trace shows only the non-tail spine. -Set `JOLT_TRACE=1` to opt into a fuller **tail-frame history**. Each compiled fn -then records itself on entry into a bounded ring-of-rings buffer, so the trace -recovers TCO-elided frames (including the immediate error site) while a tight tail -loop stays bounded and its non-tail caller context is preserved. It costs a small -per-call overhead, so it is off by default and never emitted into a built binary. +A fuller **tail-frame history** recovers the frames TCO erases: each compiled fn +records itself on entry into a bounded ring-of-rings buffer, so the trace shows +TCO-elided frames (including the immediate error site) while a tight tail loop +stays bounded and its non-tail caller context is preserved. + +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 diff --git a/host/chez/compile-eval.ss b/host/chez/compile-eval.ss index 95182ff..3706024 100644 --- a/host/chez/compile-eval.ss +++ b/host/chez/compile-eval.ss @@ -110,13 +110,24 @@ ;; older seed during the first re-mint pass. (let ((scv (var-deref "jolt.backend-scheme" "set-var-cache!"))) (when (procedure? scv) (scv #t))) -;; JOLT_TRACE opts into tail-frame history: the emitter prepends a frame-recording -;; push to every runtime-compiled fn, and the ring buffer is allocated for this -;; thread. Off by default, so a normal run emits and pays exactly as before. -(when (getenv "JOLT_TRACE") - (let ((stf (var-deref "jolt.backend-scheme" "set-trace-frames!"))) - (when (procedure? stf) (stf #t))) - (jolt-trace-enable!)) +;; Tail-frame history. Turning it on makes the emitter add a per-fn history push to +;; every fn compiled AFTERWARD, and allocates this thread's ring. Suppressed when +;; JOLT_TRACE is explicitly falsey, so JOLT_TRACE=0 disables it even in dev mode. +(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!"))) + (when (procedure? stf) (stf #t))) + (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 ;; reader metadata as a with-meta form; strip it to read the bare ns symbol. diff --git a/host/chez/smoke.sh b/host/chez/smoke.sh index 7648257..6b17f4a 100755 --- a/host/chez/smoke.sh +++ b/host/chez/smoke.sh @@ -224,5 +224,25 @@ else fails=$((fails + 1)) 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" [ "$fails" -eq 0 ] diff --git a/host/chez/source-registry.ss b/host/chez/source-registry.ss index 45928bd..f88ce2a 100644 --- a/host/chez/source-registry.ss +++ b/host/chez/source-registry.ss @@ -145,10 +145,11 @@ ;; TCO-truncated non-tail spine. ;; 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). -(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)) - ;; 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 '())) (if (null? ns) (reverse acc) @@ -156,12 +157,23 @@ (loop (cdr ns) (if (or src (not (srcreg-plumbing-name? nm))) (cons (cons nm src) acc) acc))))))) - (if (pair? recs) - (jolt-render-recs recs) - (let ((k (jolt-error-continuation v))) - (and k - (let ((recs (jolt-frame-records k))) - (and (pair? recs) (jolt-render-recs recs)))))))) + (and (pair? recs) (jolt-render-recs recs)))) + +(define (jolt-backtrace-string v) + (or (jolt-history-backtrace) + (let ((k (jolt-error-continuation v))) + (and k + (let ((recs (jolt-frame-records k))) + (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" 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: ;; an ex-info shows its message + ex-data (+ a host cause); anything else is diff --git a/jolt-core/jolt/main.clj b/jolt-core/jolt/main.clj index 1dbf8a6..9254099 100644 --- a/jolt-core/jolt/main.clj +++ b/jolt-core/jolt/main.clj @@ -147,6 +147,9 @@ ;; loaded — same context a run gets, so (require '[some.lib]) works in the REPL. (try (apply-project! (deps/resolve-project (project-dir))) (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")) (loop [] (let [form (repl-read-form)] @@ -160,7 +163,9 @@ (catch :default e (println "error:" (or (ex-message e) (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))))))) ;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}. diff --git a/jolt-core/jolt/nrepl.clj b/jolt-core/jolt/nrepl.clj index e7b55b2..96056c8 100644 --- a/jolt-core/jolt/nrepl.clj +++ b/jolt-core/jolt/nrepl.clj @@ -188,7 +188,10 @@ (try (when (and ns-str (not (str/blank? ns-str)) (find-ns (symbol ns-str))) (in-ns (symbol ns-str))) (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)) :out out :ns (str (ns-name *ns*)) @@ -277,6 +280,11 @@ no-op." ([port] (start port nil)) ([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 []))) fd (listen-socket port) ; throws on bind/listen failure stopped (atom false)]