1

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?

  • 1
    interesting idea to use a java debugger for Clojure. I'm not sure if that will work though. Emacs Cider has a Clojure debugger, but it has some quirks, and it seems lately that it has some shortcomings. Intellij Cursive at one time had a good Clojure debugger, and it still may. After a while of doing Clojure, I've learned to cope without a debugger, and it seems other people have as well. – Frank Henard Feb 16 '21 at 19:46
  • Yep - I've been using Clojure since 2009 and I haven't used a debugger for Clojure either but I have an interest in creating a VS Code extension that would leverage the Java Debug Wire Protocol - https://docs.oracle.com/en/java/javase/15/docs/specs/jdwp/jdwp-spec.html and I don't think there's a reason it shouldn't work with Clojure generated byte code. – Jon Seltzer Feb 16 '21 at 22:21
  • 2
    In principle, it should work with every Java debugger, not only the command line tool. All it takes, is a text based source code file and the line debug attribute in the class file. I demonstrated this principle in [this answer](https://stackoverflow.com/a/49405747/2711488). In practice, I already step-debugged through an XML file for which Xalan had generated byte code. In your case, I’d check whether classes had been compiled with some “optimize” or “strip-debug” option. I don’t know about clojure specifically, but every compiler I know of has similar options. – Holger Feb 17 '21 at 08:53

1 Answers1

3

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.

Dharman
  • 30,962
  • 25
  • 85
  • 135
Denis Fuenzalida
  • 3,271
  • 1
  • 17
  • 22
  • Thanks for the detailed response. It seems lein is doing something my maven plugin is not. I do not get the LineNumberTable in my compiled code. Have you tried validating with javap class files generated directly from the compile function? – Jon Seltzer Feb 17 '21 at 13:52
  • I was able to use the `compile` function and the results were very similar but some extra entries on the `LineNumberTable` entry. See this gist for more details: https://gist.github.com/dfuenzalida/664e1e5160c3f92046c260fd8500133f – Denis Fuenzalida Feb 18 '21 at 03:05
  • I'm using Clojure 1.10.1 on Linux x64 with openjdk version "11.0.10" 2021-01-19 – Denis Fuenzalida Feb 18 '21 at 03:08
  • See gist - https://gist.github.com/seltzer1717/9f0f647e5a035f0334d2eceff2bcf755 I get LineNumberTable entries but my -main function is treated as a single line, am wondering if this is a Linux vs Windows issue. – Jon Seltzer Feb 18 '21 at 13:59
  • I also purposely introduced a NullPointerException and ran the code via java command and am noticing that the exception shows 'Unknown source': ... at clojure.lang.RestFn.invoke(RestFn.java:397) at clojure.lang.AFn.applyToHelper(AFn.java:152) at clojure.lang.RestFn.applyTo(RestFn.java:132) at cloud.seltzer1717.core.main(Unknown Source) – Jon Seltzer Feb 18 '21 at 14:27
  • I've accepted the answer but still believe there is a problem with line numbers and compilation. – Jon Seltzer Feb 18 '21 at 14:30