Clojure is a language that runs on the JVM. The Clojure compiler compiles and emits JVM byte code. 'jdb' is a jdk tool, a Java debugger tool, that can be used to set breakpoints, step through code, and display variable values. However, when I run jdb on compiled Clojure class files, I get an error saying there is no line number information in the compiled classes. I thought Clojure compiled debug information into the JVM byte code. Does anyone know why I would get this error?
I've used javap, another jdk tool, to verify that, in fact, there is no debug information in the class file.
To elaborate, I'm trying to understand why the compile function in Clojure fails to attach line numbers by default. That seems to be what the documentation implies - https://clojure.org/reference/compilation. Here's the simple case:
(ns com.example.core
(:gen-class
:name com.example.core
:main true))
(defn -main [& args]
(let [foo "foo"
foo-cap "FOO"
bar "bar"]
bar)))
user=>(load "com/example/core")
user=>(compile 'com.example.core)
javap -cp ... com.example.core
Do you see a LineNumberTable?
It is possible to debug Clojure bytecode with jdb
but it's not very practical (read tedious) and maybe some information is missing to map from the compiled bytecode to the original source files, but I did an small test to verify it works (at least partially, setting breakpoints when entering a method instead).
I'll create a new Clojure project with Leiningen: lein new app demo
. Now, I'll update the file src/demo/core.clj
with the following contents:
(ns demo.core
(:gen-class))
(defn x2 [n]
(println "Doubling" n)
(let [x (* n 2)]
x))
(defn -main
[& args]
(let [xs (mapv x2 (range 10))]
(doseq [x xs]
(println x))))
Now, let's run lein uberjar
to compile the sources to bytecode:
$ lein uberjar
Compiling demo.core
Created /tmp/demo/target/uberjar/demo-0.1.0-SNAPSHOT.jar
Created /tmp/demo/target/uberjar/demo-0.1.0-SNAPSHOT-standalone.jar
I'll inspect the files generated under the target
directory:
$ tree target
target
└── uberjar
├── classes
│ ├── demo
│ │ ├── core$fn__173.class
│ │ ├── core$loading__6721__auto____171.class
│ │ ├── core$_main.class
│ │ ├── core$x2.class
│ │ ├── core.class
│ │ └── core__init.class
...
We can see that the compiler uses inner classes (those with core$
in their name) and our function x2
is compiled to a class.
In order to run the Clojure in jdb
, we need to construct a classpath that contains our code, the Clojure runtime and, in Clojure 1.10+ also some dependencies of the Clojure runtime (Spec). You can borrow most of the routes by looking at the output of lein classpath
:
$ lein classpath
/tmp/demo/test:/tmp/demo/src:/tmp/demo/dev-resources:/tmp/demo/resources:/tmp/demo/target/default/classes:/home/denis/.m2/repository/org/clojure/clojure/1.10.1/clojure-1.10.1.jar:/home/denis/.m2/repository/org/clojure/spec.alpha/0.2.176/spec.alpha-0.2.176.jar:/home/denis/.m2/repository/org/clojure/core.specs.alpha/0.2.44/core.specs.alpha-0.2.44.jar:/home/denis/.m2/repository/nrepl/nrepl/0.7.0/nrepl-0.7.0.jar:/home/denis/.m2/repository/clojure-complete/clojure-complete/0.2.5/clojure-complete-0.2.5.jar
I will remove some of these JARs and build my classpath to run jdb
with the class demo.core
which I know is the entry point:
$ jdb -classpath target/uberjar/classes:/home/denis/.m2/repository/org/clojure/clojure/1.10.1/clojure-1.10.1.jar:/home/denis/.m2/repository/org/clojure/spec.alpha/0.2.176/spec.alpha-0.2.176.jar:/home/denis/.m2/repository/org/clojure/core.specs.alpha/0.2.44/core.specs.alpha-0.2.44.jar demo.core
Before running jdb
, I want to put a breakpoint somewhere to validate. The x2
function should be a good starting point, but we need to inspect the bytecode a little to understand where in the bytecode to put the breakpoint. Using javap
will give us some clues:
$ javap -l target/uberjar/classes/demo/core\$x2.class
Compiled from "core.clj"
public final class demo.core$x2 extends clojure.lang.AFunction {
public demo.core$x2();
LineNumberTable:
line 4: 0
public static java.lang.Object invokeStatic(java.lang.Object);
LineNumberTable:
line 4: 0
line 6: 26
LocalVariableTable:
Start Length Slot Name Signature
30 3 1 x Ljava/lang/Object;
0 33 0 n Ljava/lang/Object;
public java.lang.Object invoke(java.lang.Object);
LineNumberTable:
line 4: 3
public static {};
LineNumberTable:
line 4: 0
}
From the above, I'll make a note to set a breakpoint in the method demo.core$x2.invokeStatic
which is notable because it has local variables. Now we start jdb
with the line from before:
$ jdb -classpath target/uberjar/classes:/home/denis/.m2/repository/org/clojure/clojure/1.10.1/clojure-1.10.1.jar:/home/denis/.m2/repository/org/clojure/spec.alpha/0.2.176/spec.alpha-0.2.176.jar:/home/denis/.m2/repository/org/clojure/core.specs.alpha/0.2.44/core.specs.alpha-0.2.44.jar demo.core
Initializing jdb ...
>
In the prompt, I'll tell jdb
to stop in the relevant method with stop in demo.core$x2.invokeStatic
. You can use the rest of the jdb
commands to step, continue and display local values as in the following session:
> stop in demo.core$x2.invokeStatic
Deferring breakpoint demo.core$x2.invokeStatic.
It will be set after the class is loaded.
> run
run demo.core
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: Set deferred breakpoint demo.core$x2.invokeStatic
Breakpoint hit: "thread=main", demo.core$x2.invokeStatic(), line=4 bci=0
main[1] locals
Method arguments:
n = instance of java.lang.Long(id=2743)
main[1] print n
n = "0"
main[1] cont
> Doubling 0
Breakpoint hit: "thread=main", demo.core$x2.invokeStatic(), line=4 bci=0
main[1] locals
Method arguments:
n = instance of java.lang.Long(id=2749)
Local variables:
main[1] print n
n = "1"
clear demo.core$x2.invokeStatic
Removed: breakpoint demo.core$x2.invokeStatic
main[1] cont
...
> Doubling 2
...
Doubling 9
0
2
4
...
16
18
The application exited
During development, this style is not comparable to the interactive experience of submitting code to a running REPL session and getting instant feedback, so it's not practical (except for very specific scenarios).
I think this is also the type of experience we had in a former team when we debugged Clojure apps vith JDWP in Eclipse, but after a while it becomes hard to track what methods in the Java bytecode map to which functions in your Java code.