Monday, January 24, 2011

Creating an interactive debugger for PHP

Last night I created a prototype for an interactive debugger for PHP without the need of any IDE-plugin. In this post I'm going to show what I found. Unfortunately you will need some time to put the environment together, but I think it's worth doing it.

My target was to create a way how you can view and modify your variables manually at runtime, only by inserting one line code. So let's overview what we are going to do and what we need:

* we are going to create a function that flushes the already generated output and a launcher to the browser
* the launcher will be an iframe that loads our debugger
* the debugger can only be loaded from a different domain then our application (we will need to set up a virtual host). I couldn't get my Firefox to start a new HTTP request against the same origin before the page load finishes (and our aim is exactly to start the debugger in the middle of the application request execution). Furthermore I couldn't get this solution work in any other browsers. I also tried chrome and opera on Ubuntu. Hopefully firefox works on windows too.
* I used apache for this prototype, I don't know if this solution works with any other SAPI-s.
* the application and the debugger will need some messaging for communication. We will do it using APC for now. First I tried to do it with plain text files to keep the example simple, but it didn't work (because of deferred flushing by the operation system I think).
* we will use the infamous eval() function to execute the commands that the user enters in the debugger console.
* increase the value of max_execution_time in your php.ini. The default 30 seconds won't be enough for debugging.

Putting the environment together


So let's do the environment setup:
* we will use two virtual hosts, local.app and local.dbg. Let's enter these into the hosts file.
* create the two virtual hosts:

/etc/apache2/sites-available/local.app
<VirtualHost *:80>
ServerName local.app
DocumentRoot /var/www/app
</VirtualHost>

/etc/apache2/sites-available/local.dbg
<VirtualHost *:80>
ServerName local.dbg
DocumentRoot /var/www/dbg
</VirtualHost>

Let's enable the new hosts. On debian-based systems it can be done by
# a2ensite local.app
# a2ensite local.dbg

If you don't have a2ensite then use:
# ln -s /etc/apache2/sites-available/local.app /etc/apache2/sites-enabled/local.app
# ln -s /etc/apache2/sites-available/local.dbg /etc/apache2/sites-enabled/local.dbg

Create the document roots and reload apache:
# mkdir /var/www/app
# ln -s  /var/www/app /var/www/dbg
# /etc/init.d/apache2 reload

Yes, we symlinked the document root of the debugger to the document root of the application. You may ask why we didn't configure the two virtual hosts with the same docroot. I tried that but that way I gave a PHP Fatar Error when I tried to load the debugger.

Creating the prototype


We will work with the following files in the example:
index.php: this will be the "application" we are going to debug
debugger-api.php: it contains the breakpoint() function that will launch the debugger
launcher.html: this contains the iframe that launches the debugger. It will be included in the breakpoint() function.
debugger.php: this will be our debugger that communicates with the breakpoint() function via APC.
debugger-ui.html: this will contain the UI loaded into the launcher iframe. It will contain some javascript to send ajax requests to debugger.php.

So let's see:

index.php:

<?php

define('DOCROOT', realpath(__DIR__) . DIRECTORY_SEPARATOR);

require DOCROOT.'debugger-api.php';

class MyClass {
 
 static $var;
 
}


// imagine a complex algorithm here
// that calculates MyClass::$var
MyClass::$var = '50';

//and at this point we want to look at our variables
//so we put a breakpoint here
breakpoint();

MyClass::$var += 2;

echo 'the final value of MyClass::$var is ' . MyClass::$var;

debugger-api.php

<?php

function breakpoint() {
 
 // turning off output compression
 apache_setenv('no-gzip', 1);
 ini_set('zlib.output_compression', 0);
 ini_set('implicit_flush', 1);
 
 // flushing any open output buffers
 while (ob_get_level()) {
  ob_end_flush();
 }
 ob_implicit_flush(1);

 // sending the launcher iframe to the browser
    include 'launcher.html';
    ob_get_flush();
    flush();

        //infinite loop used for messaging with debugger.php
 while(TRUE) {
  usleep(100000);
  $request = apc_fetch('request', $success);
  if ($success) {
   apc_delete('request');
   
   if ($request == 'continue') {
    apc_store('response', 'continue');
    return; //finish breakpoint, continue request execution
   } 
   
   $response = eval("return $request;");
   apc_store('response', $response);
  }
 }
}

launcher.html: it also contains some boilerplate HTML, I couldn't get Firefox to render my iframe without it

<html>
<head>
</head>
<body>
 <iframe width="500" height="300" src="http://local.dbg/debugger.php" id="launcher"></iframe>
</body>
</html>

debugger.php:

<?php

define('DOCROOT', realpath(__DIR__) . DIRECTORY_SEPARATOR);

// if no 'exec' key in $_GET then it's the first request caused by the launcher iframe
// so we need to render the debugger UI
if ( ! array_key_exists('exec', $_GET)) {
  include DOCROOT.'debugger-ui.html';
  die();
}

//sending the request to the breakpoint function
apc_store('request', $_GET['exec']);

//and waiting for response in an infinte loop
while(TRUE) {
 usleep(100000);
 $response = apc_fetch('response', $success);
 if ($success) {
  apc_delete('response');
  // sending back the response for the debugger UI
  var_dump($response);
  break;
 }
}

debugger-ui.html:

<textarea cols="30" rows="10" id="out"></textarea><br/>
<input type="text" id="in" size="25"/>
<input type="button" value="Execute" id="exec"/>
<input type="button" value="Continue" id="cont"/>

<script type="text/javascript">

 document.getElementById('exec').onclick = function() {
  var xmlhttp = new XMLHttpRequest();
  
  var stmt = document.getElementById('in').value;
  
  xmlhttp.open('GET', 'http://local.dbg/debugger.php?exec='+stmt, true)
  xmlhttp.onreadystatechange = function() {
   if (xmlhttp.readyState == 4) {
    var out = document.getElementById('out');
    out.innerHTML = out.innerHTML + xmlhttp.responseText + "\n";
   }
  }
  
  xmlhttp.send();
 }
 
 document.getElementById('cont').onclick = function() {
  var xmlhttp = new XMLHttpRequest();
  
  xmlhttp.open('GET', 'http://local.dbg/debugger.php?exec=continue', true)
  xmlhttp.onreadystatechange = function() {
   if (xmlhttp.readyState == 4) {
    var out = document.getElementById('out');
    out.innerHTML = out.innerHTML + xmlhttp.responseText + "\n";
   }
  }
  
  xmlhttp.send();
 }

</script>

So, that's all. The debugger looks like this for me:


Now if you type MyClass::$var into the text input and press the "Execute" button then you should see the value 50. Furthermore you can change it's value by typing MyClass::$var = 20. By clicking on the "Continue" button you can finish the debugging session and the request execution will be finished.

Please note that the code above is only a prototype and not a production-ready solution. I only wanted to show the key concepts. Furthermore please don't care about security and design issues, this post is about showing the way how to hack this stuff together and not about presenting my software engineering skills..

Later on I will develop something more usable based on this prototype, and I hope I'm not the only one. Some ideas if you are going to do something similar:
* show memory usage and let your user to profile the code
* create a nice GUI for introspection, this error-prone console is not a good thing. Parse var_dump() outputs, etc, free your mind :)
* use XDebug to show detailed information about the current state of the application

If you publish a debugger based on this stuff then please inform me, thank you. I'm also happy to answer any questions at crystal@cyclonephp.com. I'm also interested in what other environments can you get the code to work. Comments are welcome.

No comments:

Post a Comment