Wednesday, October 2, 2013

Write an auto-debugger to catch Exceptions during test execution

Previously I have stated that there are some exceptions you would always want to keep an debugger breakpoint on for. This help prevents code rotting away without you noticing - sometimes masking a different problem.

If you take this seriously then it is a good idea to extend this idea to you automated testing; but coming up with a comprehensive solution is not entirely trivial. You could just start with a try/catch but that won't capture exceptions on other threads. You could also do something using AOP; but depending on the framework you are not guaranteed to catch everything also it does mean you are testing with slightly different code which will worry some.

A few days ago I came across this blog entry on how to write your own debugger, and I wondered if it was possible for java process to debug itself. Turn out you can and here is the code I came up with as part of this little thought experiment.

The first part of the class just contains something fairly hacky code to guess what the port that would be required to connect back to the same VM based on the start up parameters. It might be possible to use the Attach mechanism to start the debugger; but I didn't seen an obvious way to get it to work. Then there are just a couple of factory methods that take a list of exceptions to look out for.

package com.kingsfleet.debug;

import com.sun.jdi.Bootstrap;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.connect.AttachingConnector;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.connect.IllegalConnectorArgumentsException;
import com.sun.jdi.event.ClassPrepareEvent;
import com.sun.jdi.event.Event;
import com.sun.jdi.event.EventQueue;
import com.sun.jdi.event.EventSet;
import com.sun.jdi.event.ExceptionEvent;
import com.sun.jdi.event.VMDeathEvent;
import com.sun.jdi.event.VMDisconnectEvent;
import com.sun.jdi.request.ClassPrepareRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.ExceptionRequest;

import java.io.IOException;

import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;


public class ExceptionDebugger implements AutoCloseable {




   public static int getDebuggerPort() {
       // Try to work out what port we need to connect to

       RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
       List<String> inputArguments = runtime.getInputArguments();
       int port = -1;
       boolean isjdwp = false;

       for (String next : inputArguments) {
           if (next.startsWith("-agentlib:jdwp=")) {
               isjdwp = true;
               String parameterString = next.substring("-agentlib:jdwp=".length());
               String[] parameters = parameterString.split(",");
               for (String parameter : parameters) {
                   if (parameter.startsWith("address")) {
                       int portDelimeter = parameter.lastIndexOf(":");
                       if (portDelimeter != -1) {
                           port = Integer.parseInt(parameter.substring(portDelimeter + 1));
                       } else {
                           port = Integer.parseInt(parameter.split("=")[1]);
                       }
                   }
               }
           }
       }
       return port;
   }


   public static ExceptionDebugger connect(final String... exceptions) throws InterruptedException {
       return connect(getDebuggerPort(),exceptions);
   }

   public static ExceptionDebugger connect(final int port, final String... exceptions) throws InterruptedException {

       ExceptionDebugger ed = new ExceptionDebugger(port, exceptions);

       return ed;
   }


The constructor create a simple daemon thread that starts the connection back to the VM. It is very important to do this is a separate thread otherwise obviously the VM will grind to a halt when we hit a breakpoint. It is a good idea to make sure that code in that thread doesn't throw one of the exceptions - for the moment I am just hoping for the best.

Finally the code just maintains a list of the banned exceptions, if you had a bit more time it should be possible to store the stack trace where the exception occurred.

// 
   // Instance variables

   private final CountDownLatch startupLatch = new CountDownLatch(1);
   private final CountDownLatch shutdownLatch = new CountDownLatch(1);

   private final Set<String> set = Collections.synchronizedSet(new HashSet<String>());
   private final int port;
   private final String exceptions[];
   private Thread debugger;
   private volatile boolean shutdown = false;


   //
   // Object construction and methods
   //




   private ExceptionDebugger(final int port, final String... exceptions) throws InterruptedException {

       this.port = port;
       this.exceptions = exceptions;

       debugger = new Thread(new Runnable() {

           @Override
           public void run() {
               try {
                   connect();
               } catch (Exception ex) {
                   ex.printStackTrace();
               }
           }
       }, "Self debugging");
       debugger.setDaemon(true); // Don't hold the VM open
       debugger.start();

       // Make sure the debugger has connected
       if (!startupLatch.await(1, TimeUnit.MINUTES)) {
           throw new IllegalStateException("Didn't connect before timeout");
       }
   }


   @Override
   public void close() throws InterruptedException {
       shutdown = true;
       // Somewhere in JDI the interrupt was being eaten, hence the volatile flag 
       debugger.interrupt();
       shutdownLatch.await();
   }


   /**
    * @return A list of exceptions that were thrown
    */
   public Set<String> getExceptionsViolated() {
       return new HashSet<String>(set);
   }

   /**
    * Clear the list of exceptions violated
    */
   public void clearExceptionsViolated() {
       set.clear();
   }


The main connect method is a fair simple block of code that ensures the connection and configures any initial breakpoints.

//
   // Implementation details
   //


   private void connect() throws java.io.IOException {


       try {
           // Create a virtual machine connection
           VirtualMachine attach = connectToVM();


           try
           {

               // Add prepare and any already loaded exception breakpoints
               createInitialBreakpoints(attach);

               // We can now allow the rest of the work to go on as we have created the breakpoints
               // we required

               startupLatch.countDown();

               // Process the events
               processEvents(attach);
           }
           finally {

               // Disconnect the debugger
               attach.dispose();

               // Give the debugger time to really disconnect
               // before we might reconnect, couldn't find another
               // way to do this

               try {
                   TimeUnit.SECONDS.sleep(1);
               } catch (InterruptedException e) {
                   Thread.currentThread().interrupt();
               }
           }
       } finally {
           // Notify watchers that we have shutdown
           shutdownLatch.countDown();
       }
   }



Connecting back to self is just a process of finding the right attaching connector, in this case Socket although I guess you could use the shared memory transport on some platforms if you modified the code slightly.

private VirtualMachine connectToVM() throws java.io.IOException {

       List<AttachingConnector> attachingConnectors = Bootstrap.virtualMachineManager().attachingConnectors();
       AttachingConnector ac = null;

       found:
       for (AttachingConnector next : attachingConnectors) {
           if (next.name().contains("SocketAttach")) {
               ac = next;
               break;

           }
       }

       Map<String, Connector.Argument> arguments = ac.defaultArguments();
       arguments.get("hostname").setValue("localhost");
       arguments.get("port").setValue(Integer.toString(port));
       arguments.get("timeout").setValue("4000");

       try {
           return ac.attach(arguments);
       } catch (IllegalConnectorArgumentsException e) {
           throw new IOException("Problem connecting to debugger",e);
       }
   }


When you connect the debugger you have no idea as to whether the exceptions you are interested in have been loaded, so you need to register breakpoints for both the point where the classes are prepared and for those that have already been loaded.

Note that the breakpoint is set with a policy only to break the one thread - otherwise for obvious reasons the current VM will grind to a halt if the debugger thread is also put to sleep.

private void createInitialBreakpoints(VirtualMachine attach) {
       // Our first exception is for class loading

       for (String exception : exceptions) {
           ClassPrepareRequest cpr = attach.eventRequestManager().createClassPrepareRequest();
           cpr.addClassFilter(exception);
           cpr.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
           cpr.setEnabled(true);
       }

       // Then we can check each in turn to see if it have already been loaded as we might
       // be late to the game, remember classes can be loaded more than once
       //

       for (String exception : exceptions) {
           List<ReferenceType> types = attach.classesByName(exception);
           for (ReferenceType type : types) {
               createExceptionRequest(attach, type);
           }
       }
   }


   private static void createExceptionRequest(VirtualMachine attach, 
                                              ReferenceType refType) {
       ExceptionRequest er = attach.eventRequestManager().createExceptionRequest(
           refType, true, true);
       er.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
       er.setEnabled(true);
   }



The event processing loop polls for EventSet instances which contain one or more Event instances. Not all of these events are down to a breakpoint request though so you have to take care to not always call resume on the event set. This is because you might have two event sets in a row, with the code calling resume before you even get to read the second one. This results in missed breakpoints as the code caught up.

For some reason JDI appeared to be eating the interrupted flag, hence the boolean property to stop the loop with the close method from before.

private void processEvents(VirtualMachine attach) {
       // Listen for events

       EventQueue eq = attach.eventQueue();
       eventLoop: while (!Thread.interrupted() && !shutdown) {

           // Poll for event sets, with a short timeout so that we can
           // be interrupted if required
           EventSet eventSet = null;
           try 
           {
               eventSet = eq.remove(500);
           }
           catch (InterruptedException ex) {
               Thread.currentThread().interrupt();
               continue eventLoop;  
           }

           // Just loop again if we have no events
           if (eventSet == null) {
               continue eventLoop;
           }

           //

           boolean resume = false;
           for (Event event : eventSet) {

               EventRequest request = event.request();
               if (request != null) {
                   int eventPolicy = request.suspendPolicy();
                   resume |= eventPolicy != EventRequest.SUSPEND_NONE;
               }

               if (event instanceof VMDeathEvent || event instanceof VMDisconnectEvent) {
                   // This should never happen as the VM will exit before this is called

               } else if (event instanceof ClassPrepareEvent) {

                   // When an instance of the exception class is loaded attach an exception breakpoint
                   ClassPrepareEvent cpe = (ClassPrepareEvent) event;
                   ReferenceType refType = cpe.referenceType();
                   createExceptionRequest(attach, refType);

               } else if (event instanceof ExceptionEvent) {

                   String name = ((ExceptionRequest)event.request()).exception().name();
                   set.add(name);
               }
           }

           // Dangerous to call resume always because not all event suspend the VM
           // and events happen asynchornously.
           if (resume)
               eventSet.resume();
       }
   }

}

So all that remains is a simple test example, since this is JDK 7 and the ExceptionDebugger is AutoCloseable we can do this using the try-with-resources construct as follows. Obviously if doing automated test use the testing framework fixtures of your choice.

public class Target {

   public static void main(String[] args) throws InterruptedException {


       try (ExceptionDebugger ex = ExceptionDebugger.connect(
               NoClassDefFoundError.class.getName())) {

           doSomeWorkThatQuietlyThrowsAnException();

           System.out.println(ex.getExceptionsViolated());
       }


       System.exit(0);
   }


   private static void doSomeWorkThatQuietlyThrowsAnException() {
       // Check to see that break point gets fired

       try {
           Thread t = new Thread(new Runnable() {
                           public void run() {
                               try
                               {
                                   throw new NoClassDefFoundError();
                               }
                               catch (Throwable ex) {

                               }
                           }
                      });
           t.start();
           t.join();
       } catch (Throwable th) {
           // Eat this and don't tell anybody
       }
   }
}


So if you run this class with the following VM parameter, note the suspend=n otherwise the code won't start running, you will find that it can connect back to itself and start running.

-agentlib:jdwp=transport=dt_socket,address=localhost:5656,server=y,suspend=n

This gives you the following output, note the extra debug line from the VM:

Listening for transport dt_socket at address: 5656
[java.lang.NoClassDefFoundError]
Listening for transport dt_socket at address: 5656

As every I would be interested to read if this was something that is useful for people and to help remove any obvious mistakes.

No comments: