Search code examples
clojurejvmjdbjvm-bytecode

Clojure and the Java Debugger


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?


Solution

  • 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.