Repository navigation
Add jolt.debug: a throwable's frames with their locals, and a break hook - #1465
Merged
Merged
Conversation
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.
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.
Contributor
Author
|
I tried this branch on more code shapes and pushed two follow-up commits.
(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} ...}
|
This was referenced Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1464.
This adds
jolt.debugand a debug mode forjolt.profile's compile switch, the shape agreed in the issue. v1 is read-only: no resuming a throw and no setting locals.The API
framesreturns the frames.getStackTracereports, innermost first.:fnis a symbol, as injolt.profile's rows, and:localsmaps each local's symbol to its value.with-break-handlerruns 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.trueand:countsstill mean the counters,falseturns 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-attachmentis 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, eachletbinding, 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:(throw ...)or a tail(/ y 0)keeps the frame it came from. The spike showed that the frame is gone otherwise.framesreads it off the captured continuation through the inspector, whatever the code reads after the call returns.recurbinds 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'trecurruns out of stack under:debugwhere it wouldn't otherwise. The namespace docstring says so.Code compiled without the mode emits exactly what it did before.
make remintrewrote bothimage.ssseeds, because they hold the back end itself, and left bothprelude.ssseeds byte-identical. The prelude is all ofclojure.coreemitted 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-namecould 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 anddynamic-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'seshows up in its fn's frame.What changed
jolt-core/jolt/backend_scheme.clj: scope records, the tail-leaf rule, andrecur, all behind the unit flag:debug-locals?(passes/types.clj). Both seeds are re-minted.host/chez/rt.ss: thejolt-dbgrecord and the host's mode flag.host/chez/source-registry.ss:jolt-profile-instrument!;jolt-frame-records, which is a thread parameter that is#foutsideframes;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 instdlib-fasl-manifest.txt.stdlib/jolt/profile.clj:instrument!takes:debug, andinstrument-modeis new.host-vars.ssstubs.:debugis refused like the counters,framesis empty, and a break handler is never called.TARGET-CONTRACT.mdnotes this.Tests
test/chez/debug-test.cljruns fromsmoke.sh. It covers:recur's args;recurrecursion keeping each frame, and a million-iterationrecurloop that doesn't grow the stack;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.JOLT_PROFILE=debugrecompiles a namespace that an earlier plain run left in the AOT cache.cts) underJOLT_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 testpasses: self-host plus all 184citargets, 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 from5cb2c089gives the identical tally, so it predates this change. 15 suites are skipped or missing locally.bench/run.shwasn'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.