JDI(Java Debug Interface)のテスト

そもそもやりたかったのは、任意の例外が発生した瞬間にそれを検知してデバッグモードに入るような機能を作ること。そのためにはデバッガもどきを作る必要があって、Pure Javaで記述できるJDIを試していたら、なんだかよく分からないものが出来上がった。

import java.io.IOException;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import com.sun.jdi.*;
import com.sun.jdi.connect.*;
import com.sun.jdi.event.*;
import com.sun.jdi.request.*;

/*
 * How to compile:
 *
 * % javac -cp .:$JDK_HOME/lib/tools.jar -Xlint:all JDITest.java
 *
 * How to run:
 * % java -cp .:$JDK_HOME/lib/tools.jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=4321 JDITest
 *
 * API reference
 * http://java.sun.com/j2se/1.5.0/ja/docs/ja/guide/jpda/jdi/
 *
 * The code is originally written by Yoshiyuki Usui.
 * http://www.geocities.jp/usui22/JavaProgram/testJDI/testJDI.html
 *
 */
public class JDITest
{
    private static Connector findConnector(String name)
    {
        VirtualMachineManager vmManager = Bootstrap.virtualMachineManager();
        List<Connector> connectors = vmManager.allConnectors();
        Iterator<Connector> it = connectors.iterator();
        while (it.hasNext()) {
            Connector connector = it.next();
            if (connector.name().equals(name))
                return connector;
        }
        return null;
    }

    public static void main(String[] args)
    {
        AttachingConnector connector = (AttachingConnector) findConnector("com.sun.jdi.SocketAttach");
        if (connector == null)
            throw new RuntimeException("No connector");
        Map<String, Connector.Argument> arguments = connector.defaultArguments();
        arguments.get("hostname").setValue("localhost");
        arguments.get("port").setValue("4321");

        VirtualMachine vm = null;
        try {
            vm = connector.attach(arguments);
        } catch (IllegalConnectorArgumentsException ex) {
            throw new RuntimeException(ex);
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }

        Debugger debugger = new Debugger(vm);
        debugger.start();

        Parent parent = new Parent();

        debugger.exit();
    }
}

class Debugger extends Thread
{
    private VirtualMachine vm;
    private EventQueue queue;

    private boolean exitRequested = false;

    public Debugger(VirtualMachine vm)
    {
        this.vm = vm;

        EventRequestManager manager = vm.eventRequestManager();

        // refType = all, notifyCaught = false, notifyUncaught = true
        ExceptionRequest req = manager.createExceptionRequest(null, false, true);

        req.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
        req.enable();
    }

    public void showThreadInfo(ThreadReference thread) throws IncompatibleThreadStateException
    {
        List<StackFrame> frames = thread.frames();
        Iterator<StackFrame> it = frames.iterator();
        while (it.hasNext()) {
            StackFrame frame = it.next();
            Location loc = frame.location();
            Method method = loc.method();
            long codeIndex = loc.codeIndex();

            System.err.println("StackFarme location:");
            try {
                System.err.println(" sourceName = " + loc.sourceName());
            } catch (AbsentInformationException ex) {
                System.err.println(" sourceName = (Unknown)");
            }
            System.err.println(" lineNumber = " + loc.lineNumber());
            System.err.println(" method     = " + method);
            try {
                byte[] bytecodes = method.bytecodes();
                System.err.println(" codeIndex  = " + codeIndex + "/" + bytecodes.length);
            } catch (UnsupportedOperationException ex) {
                System.err.println(" codeIndex  = " + codeIndex);
            }
        }
    }

    public void processExceptionEvent(ExceptionEvent ee)
    {
        ThreadReference thread = ee.thread();
        try {
            // showThreadInfo(thread);

            thread.suspend();
            List<StackFrame> frames = thread.frames();
            if (! frames.isEmpty()) {
                StackFrame frame = frames.get(0);
                thread.popFrames(frame);
            }
            thread.resume();
        } catch (IncompatibleThreadStateException ex) {
            System.err.println("ThreadReference.frame() => " + ex);
        }
    }

    public void run()
    {
        queue = vm.eventQueue();
        while (! exitRequested) {
            try {
                EventSet events = queue.remove(1000);
                if (events == null) continue;

                EventIterator it = events.eventIterator();
                while (it.hasNext()) {
                    Event event = it.nextEvent();
                    if (event instanceof ExceptionEvent) {
                        processExceptionEvent((ExceptionEvent) event);
                    }
                }
                events.resume();
            } catch (InterruptedException ex) {
                // System.err.println("Debugger: interrupted");
                break;
            }
        }
        System.err.println("Debugger: exited.");
    }

    public void exit()
    {
        exitRequested = true;
        interrupt();
    }
}
class Parent
{
    public Parent()
    {
        Child alice = new CleverChild("Alice");
        Child bob = new CommonChild("Bob");
        Child charlie = new DullChild("Charlie");

        try {
            Thread.sleep(3000);
        } catch (InterruptedException ex) {
        }

        watch(alice);
        watch(bob);
        watch(charlie);
    }

    public void watch(Child child)
    {
        try {
            child.join();
        } catch (InterruptedException e) {
            // ignored.
        }
    }
}
abstract class Child extends Thread
{
    private int age;

    public int getAge() { return age; }

    abstract boolean isBaby();

    public Child(String name)
    {
        super(name);
        age = 0;
        start();
    }

    public void growUp()
    {
        age++;
        if (isBaby())
            throw new RuntimeException("Oops, he/she is too young to speak!");
    }

    public void run()
    {
        growUp();
        System.err.println("Hello! I'm " + getName() + ".");
    }
}

class CleverChild extends Child
{
    public CleverChild(String name) { super(name); }
    public boolean isBaby() { return getAge() < 1; }
}

class CommonChild extends Child
{
    public CommonChild(String name) { super(name); }
    public boolean isBaby() { return getAge() < 2; }
}

class DullChild extends Child
{
    public DullChild(String name) { super(name); }
    public boolean isBaby() { return getAge() < 3; }
}

実行結果:

$ java -cp .:/usr/lib/java/lib/tools.jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=4321 JDITest
Listening for transport dt_socket at address: 4321
Hello! I'm Alice.
Hello! I'm Bob.
Exception in thread "Charlie" java.lang.RuntimeException: Oops, he/she is too young to speak!
        at Child.growUp(Child.java:20)
        at Child.run(Child.java:25)
Debugger: exited.

この例のポイントはChildクラスのgrowUpメソッドで、例外が発生するとデバッガに処理が移り、その中でスタックフレームを1つ戻すことでgrowUpメソッドが再実行される。その結果、本来例外が発生するはずのCommonChildで例外がキャンセルされ、"Hello! I'm Bob."という出力が行われている(DebuggerクラスのprocessExceptionEvent()でshowThreadInfo()の呼び出しをコメントアウトしている部分を有効にすると、複数回呼ばれていることがよりはっきり分かる)。

ところで、DullChildクラスのcharlieが例外を投げて終了しているのは何故だろう。この例外捕捉→再実行の仕組みは2回以上動かないのか?

追記


例外発生時にデバッガを起動するには、このようにすればいいらしい(JPDA の接続および呼び出しより)。

$ java -agentlib:jdwp=transport=dt_socket,server=y,onthrow=java.lang.Exception,launch=/usr/lib/java/bin/jdb Test

確かにデバッガは起動するけど、これだとデバッグできないから意味ないな。

public class Test
{
    public static void test() throws Throwable
    {
        int x = 1;
        throw new Exception("test");
    }

    public static void main(String[] args)
    {
        try {
            test();
        } catch (Throwable th) {
            th.printStackTrace();
        }
    }
}

追記2

launcher=のところに以下のようなシェルスクリプトを設定したらいけた。

  • debugstub.sh:
#!/bin/sh

transport=$1
address=$2
jdb -attach localhost:$address

最初$addressをそのまま渡したらConnection refusedになってハマった。

せっかくだからちょっと遊んでみる。

$ java -agentlib:jdwp=transport=dt_socket,server=y,onthrow=java.lang.Exception,launch=./debugstub.sh Test
uncaught java.lang.Throwable を設定しました
保留した uncaught java.lang.Throwable を設定しました
jdb の初期化中です...
> Exception in thread "event-handler" java.lang.NullPointerException
        at com.sun.tools.example.debug.tty.TTY.exceptionEvent(TTY.java:110)
        at com.sun.tools.example.debug.tty.EventHandler.exceptionEvent(EventHandler.java:261)
        at com.sun.tools.example.debug.tty.EventHandler.handleEvent(EventHandler.java:105)
        at com.sun.tools.example.debug.tty.EventHandler.run(EventHandler.java:79)
        at java.lang.Thread.run(Thread.java:595)

> threads
グループ system:
  (java.lang.ref.Reference$ReferenceHandler)0x151 Reference Handler 状況待機中
  (java.lang.ref.Finalizer$FinalizerThread)0x152  Finalizer         状況待機中
  (java.lang.Thread)0x153                         Signal Dispatcher 実行中
グループ main:
  (java.lang.Thread)0x155                         main              実行中
> thread 0x155
main[1] list
2    {
3        public static void test() throws Throwable
4        {
5            int x = 1;
6 =>         throw new Exception("test");
7        }
8
9        public static void main(String[] args)
10        {
11            try {
main[1] locals
メソッド引数:
ローカル変数:
x = 1
main[1] exit

なお、ローカル変数の情報を利用するためにはTest.javaコンパイルの際に-gオプションを付ける必要がある(ということに気付かなくてさらにハマった)。