Skip to content

Add jolt.debug: a throwable's frames with their locals, and a break hook - #1465

Merged
yogthos merged 4 commits into
jolt-lang:mainfrom
burinc:feat/jolt-debug
Oct 30, 2026
Merged

yogthos merged 4 commits into
jolt-lang:mainfrom
burinc:feat/jolt-debug

Conversation

@burinc

@burinc burinc commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Closes #1464.

This adds jolt.debug and a debug mode for jolt.profile's compile switch, the shape agreed in the issue. v1 is read-only: no resuming a throw and no setting locals.

The API

(require '[jolt.debug :as dbg] '[jolt.profile :as prof])

(prof/instrument! :debug)          ; or JOLT_PROFILE=debug for a whole run
(require 'my.app :reload)
(prof/instrument-mode)             ; => :debug   (:counts, :debug or nil)

(dbg/frames t)
;; => [{:fn my.app/inner :file "src/my/app.clj" :line 4 :locals {x 21 y 42}}
;;     {:fn my.app/outer :file "src/my/app.clj" :line 8 :locals {a 20 b 21}} ...]

(dbg/with-break-handler (fn [throwable frames] ...) body...)
(dbg/call-with-break-handler handler f)   ; the fn form
  • frames returns the frames .getStackTrace reports, innermost first. :fn is a symbol, as in jolt.profile's rows, and :locals maps each local's symbol to its value.
  • with-break-handler runs its body. When a throw escapes it, the handler gets (throwable frames) on the throwing thread before the stack unwinds, and then the throw is rethrown. The handler may block, and the frames stay live while it does. A throw that a catch inside the body handles never reaches it. If the handler throws, that throw propagates instead.
  • (prof/instrument! :debug) is a third mode of the existing switch. true and :counts still mean the counters, false turns either mode off, and one mode is on at a time. Like the counters, debug mode turns trace frames on and keeps the AOT cache out of the way.

How the locals travel

The issue talked about a continuation mark. Chez 10.4 has no continuation attachments (call-setting-continuation-attachment is unbound), so this uses the way the _ss$/_so$ site slots already ride along: a variable the frame keeps live across its calls.

Under :debug, every scope binds a small chain record in a fresh _dl$ variable. That covers an arity's params, each let binding, and each loop iteration. Each tail leaf is then emitted as a non-tail call followed by (keep-live _dl$N). That does two things:

  • The fn's frame exists while the leaf runs. A tail (throw ...) or a tail (/ y 0) keeps the frame it came from. The spike showed that the frame is gone otherwise.
  • The record is live across every call in the scope. frames reads it off the captured continuation through the inspector, whatever the code reads after the call returns.

recur binds its args to temps, keeps the scope live past them, and still jumps in tail position, so loops don't grow the stack. Every other call in debug-compiled code becomes a real call, which is what the JVM does. Deep recursion that isn't recur runs out of stack under :debug where it wouldn't otherwise. The namespace docstring says so.

Code compiled without the mode emits exactly what it did before. make remint rewrote both image.ss seeds, because they hold the back end itself, and left both prelude.ss seeds byte-identical. The prelude is all of clojure.core emitted by the new back end.

Frames from code compiled without the mode get whatever Chez kept live, under their Clojure names. A name that munge-name could not have produced is dropped, which removes the back end's own temporaries (_ss$12, loop3). The analyzer's _r$ temporaries are left out of the debug records too.

A catch body and a try body run in closures of guard's and dynamic-wind's, which the trace never names. When such a closure's chain extends the chain of the fn it sits in, the fn's frame reports the closure's locals, so a catch's e shows up in its fn's frame.

What changed

  • jolt-core/jolt/backend_scheme.clj: scope records, the tail-leaf rule, and recur, all behind the unit flag :debug-locals? (passes/types.clj). Both seeds are re-minted.
  • host/chez/rt.ss: the jolt-dbg record and the host's mode flag.
  • host/chez/source-registry.ss:
    • the mode handling in jolt-profile-instrument!;
    • a locals sink in jolt-frame-records, which is a thread parameter that is #f outside frames;
    • the rows and the break hook (with-exception-handler, so the handler runs before unwinding).
  • host/chez/loader.ss: the AOT cache is skipped under :debug, as it is for the counters.
  • host/chez/cli-core.ss: JOLT_PROFILE=debug.
  • stdlib/jolt/debug.clj: the new namespace, plus its line in stdlib-fasl-manifest.txt.
  • stdlib/jolt/profile.clj: instrument! takes :debug, and instrument-mode is new.
  • Gambit: host-vars.ss stubs. :debug is refused like the counters, frames is empty, and a break handler is never called. TARGET-CONTRACT.md notes this.

Tests

  • test/chez/debug-test.clj runs from smoke.sh. It covers:
    • a tail throw and a tail call keeping both frames and their locals;
    • loop bindings inside recur's args;
    • a catch local;
    • non-recur recursion keeping each frame, and a million-iteration recur loop that doesn't grow the stack;
    • shadowing, rest params, destructuring, and an anonymous fn's frame;
    • an uninstrumented frame's live local;
    • for the break hook: that it rethrows, that it runs before a finally, that it ignores a catch inside the body, that a throwing handler's throw propagates, that it runs on the throwing thread, and that a blocked handler holds the stack until it returns.
  • A second smoke case checks that JOLT_PROFILE=debug recompiles a namespace that an earlier plain run left in the AOT cache.
  • Stress run: the clojure-test-suite gate (cts) under JOLT_PROFILE=debug, which compiles the whole suite in debug mode and holds it to the same per-namespace baseline. It matched: 243 namespaces, pass 6094, fail 126, error 0. sci's functional gate also passes with sci compiled under :debug, and sci's frames carry their locals.

Gates

  • make test passes: self-host plus all 184 ci targets, with the gambit gates and certify included. It was run on macOS arm64 with Chez 10.4.1.
  • make libconformance: 31 ok, 3 better, and 1 regressed. The regression is rewrite-clj (load-fail=1, 124 of 163 tests). A jolt built from 5cb2c089 gives the identical tally, so it predates this change. 15 suites are skipped or missing locally.
  • Benchmarks: bench/run.sh wasn't run. With the mode off the emitted code is byte-identical, as the unchanged prelude seeds show, so there's no throughput change to measure. Debug-compiled code is slower by design.

jolt.profile's compile switch gains a :debug mode (instrument! :debug, or
JOLT_PROFILE=debug). Code compiled under it binds a scope record per scope,
an arity's params, each let binding and each loop iteration, and keeps the
innermost live across every call: each tail leaf becomes a non-tail call
followed by keep-live, and recur keeps the scope live past its args and
still jumps. Chez 10.4 has no continuation attachments, so the locals ride
in the frame the way the _ss$/_so$ site slots do. Off, the back end emits
exactly what it did (both prelude seeds are byte-identical after remint).

jolt.debug/frames reads a throwable's capture into {:fn :file :line :locals}
maps, innermost first. Frames from other code get what Chez kept live.
jolt.debug/with-break-handler calls (handler throwable frames) on the
throwing thread before the stack unwinds, then rethrows.

Closes jolt-lang#1464.
@burinc
burinc requested a review from yogthos October 30, 2026 15:58
A record or defmethod body runs in a frame the trace cannot name. Its debug
chain was handed to the next named frame whenever that frame had no chain of
its own, so an uninstrumented caller showed the method's locals. It now goes
only to a frame whose own chain it extends.
A defmethod's body and a record's or type's method are fn literals no def
covers, so the trace drops their frames and jolt.debug/frames lost them with
their locals. A form compiled under :debug now keeps its file and labels
beside its marker table, evicted with it, and a jolt.debug walk names such a
frame ns/Type.method or ns/mm[dispatch] (else ns/fn--n) at the line it is
at. .getStackTrace is unchanged. A bare (gensym) local, G__n, which is how
destructure names a destructured arg, is left out of the records too.
@burinc

burinc commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

I tried this branch on more code shapes and pushed two follow-up commits.

  • abdb63d0 fixes a bug. A record method's or defmethod's body runs in a frame the trace can't name. Its locals were being shown as those of whichever named frame came next. The locals now go only to a frame whose own chain they extend, and there's a regression test that fails without the fix.
  • d72316b1 names those frames. A form compiled under :debug now keeps its file and form labels next to its marker table, and they're evicted with it. A frames walk then reports the frame as jolt.profile would name it, with its locals:
(defmethod area :sq [{:keys [w]}] (let [a (* w w)] (/ a 0)))
;; {:fn debug-fixture/area[:sq] :locals {w 3 a 9} ...}
(defrecord Bomb [k] Boom (boom [this n] (let [m (+ k n)] (/ m 0))))
;; {:fn debug-fixture/Bomb.boom :locals {this #Bomb{:k 4} n 5 k 4 m 9} ...}

.getStackTrace still leaves these frames out, as it does on main. That's a separate gap. Bare (gensym) locals (G__n, which destructure uses for a destructured arg) are now left out of the debug records too. That needed a remint, and both prelude seeds stayed byte-identical.

make test passes again on d72316b1: self-host plus all 184 ci targets.

@yogthos
yogthos merged commit 94da94f into jolt-lang:main Oct 30, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: jolt.debug, frames with locals for a caught throwable and a break hook before the stack unwinds

2 participants