Major changes to webserver backend. All socket functions are abstracted to allow support for stream_* which seems to be both more widely supported and better at handling blocking and timeouts, at the cost of a small bit of speed. Keep-Alive times out properly and thanks to a bit of IPC code from stream_create_pair(), zombie children are mostly eliminated by proper pcntl_wait() being called when a child shuts down normally, and children die within 0.2sec if the parent receives a SIGTERM or SIGINT, even if the children are waiting on the socket.
authorDan
Sun, 24 Aug 2008 01:28:52 -0400
changeset 37 65e70ada71c9
parent 36 70ef461bbffa
child 38 87fe0dec1536
Major changes to webserver backend. All socket functions are abstracted to allow support for stream_* which seems to be both more widely supported and better at handling blocking and timeouts, at the cost of a small bit of speed. Keep-Alive times out properly and thanks to a bit of IPC code from stream_create_pair(), zombie children are mostly eliminated by proper pcntl_wait() being called when a child shuts down normally, and children die within 0.2sec if the parent receives a SIGTERM or SIGINT, even if the children are waiting on the socket.
webserver.php
--- a/webserver.php	Sun Aug 24 01:26:20 2008 -0400
+++ b/webserver.php	Sun Aug 24 01:28:52 2008 -0400
@@ -36,6 +36,12 @@
 define('HTTPD_ICON_FILE',   'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAINSURBVBgZBcG/r55zGAfg6/4+z3va01NHlYgzEfE7MdCIGISFgS4Gk8ViYyM2Mdlsko4GSf8Do0FLRCIkghhYJA3aVBtEz3nP89wf11VJvPDepdd390+8Nso5nESBQoq0pfvXm9fzWf19453LF85vASqJlz748vInb517dIw6EyYBIIG49u+xi9/c9MdvR//99MPPZ7+4cP4IZhhTPbwzT2d+vGoaVRRp1rRliVvHq+cfvM3TD82+7mun0o/ceO7NT+/4/KOXjwZU1ekk0840bAZzMQ2mooqh0A72d5x/6sB9D5zYnff3PoYBoWBgFKPKqDKqjCpjKr//dcu9p489dra88cydps30KswACfNEKanSaxhlntjJ8Mv12Paie+vZ+0+oeSwwQ0Iw1xAR1CiFNJkGO4wu3ZMY1AAzBI0qSgmCNJsJUEOtJSMaCTBDLyQ0CknAGOgyTyFFiLI2awMzdEcSQgSAAKVUmAeNkxvWJWCGtVlDmgYQ0GFtgg4pNtOwbBcwQy/Rife/2yrRRVI0qYCEBly8Z+P4qMEMy7JaVw72N568e+iwhrXoECQkfH91kY7jwwXMsBx1L93ZruqrK6uuiAIdSnTIKKPLPFcvay8ww/Hh+ufeznTXu49v95IMoQG3784gYXdTqvRmqn/Wpa/ADFX58MW3L71SVU9ETgEIQQQIOOzub+fhIvwPRDgeVjWDahIAAAAASUVORK5CYII=');
 
 /**
+ * Abstraction layer to use
+ */
+
+define('HTTPD_SOCKET_LAYER', 'Stream');
+
+/**
  * Simple but full-featured embedded web server written in PHP.
  * @package Amarok
  * @subpackage WebControl
@@ -54,11 +60,11 @@
   var $bind_address = '127.0.0.1';
   
   /**
-   * Socket resource
-   * @var resource
+   * Socket abstraction object
+   * @var object
    */
   
-  var $sock = null;
+  var $server = null;
   
   /**
    * Server string
@@ -174,6 +180,15 @@
   var $parent_pid = 0;
   
   /**
+   * Sockets for parent and child to communicate
+   * @var resource
+   * @var resource
+   */
+  
+  var $parent_sock = null;
+  var $child_sock = null;
+  
+  /**
    * Constructor.
    * @param string IPv4 address to bind to
    * @param int Port number
@@ -244,16 +259,9 @@
     }
     $socket_do_root = ( $allow_root ) ? function_exists('posix_geteuid') : false;
     
-    $this->sock = @socket_create(AF_INET, SOCK_STREAM, getprotobyname('tcp'));
-    if ( !$this->sock )
-      throw new Exception('Could not create socket');
-    $result = @socket_bind($this->sock, $address, $port);
-    if ( !$result )
-      throw new Exception("Could not bind to $address:$port");
-    $this->socket_initted = true;
-    $result = @socket_listen($this->sock, SOMAXCONN);
-    if ( !$result )
-      throw new Exception("Could not listen for connections $address:$port");
+    $class = 'Socket_' . HTTPD_SOCKET_LAYER;
+    $this->server = new $class();
+    $this->server->tcp_listen($address, $port);
     
     // if running as root and we made it here, switch credentials
     if ( $socket_do_root )
@@ -277,6 +285,7 @@
                   substr($uuid_base, 12, 4) . '-' .
                   substr($uuid_base, 16, 4) . '-' .
                   substr($uuid_base, 20, 20);
+                  
   }
   
   /**
@@ -289,13 +298,24 @@
     {
       if ( function_exists('status') )
         status('WebServer: destroying socket');
-      // http://us3.php.net/manual/en/function.socket-bind.php
-      if ( !@socket_set_option($this->sock, SOL_SOCKET, SO_REUSEADDR, 1) )
+      $this->server->destroy();
+      
+      // tell all children to shut down
+      if ( $this->allow_fork )
       {
-        echo socket_strerror(socket_last_error($sock)) . "\n";
+        if ( function_exists('status') )
+          status('WebServer: asking all children to exit');
+        $this->send_ipc_event("die _");
       }
-      @socket_shutdown($this->sock, 2);
-      @socket_close($this->sock);
+      
+      // that last operation should have been asynchronous, so shut everything down now
+      @socket_shutdown($this->parent_sock);
+      @socket_close($this->parent_sock);
+    }
+    else if ( defined('HTTPD_WS_CHILD') )
+    {
+      @socket_shutdown($this->child_sock);
+      @socket_close($this->child_sock);
     }
   }
   
@@ -305,6 +325,24 @@
   
   function serve()
   {
+    // If we're allowed to use multithreading, set up to handle SIGUSR2 which waits on the child
+    if ( function_exists('pcntl_signal') && $this->allow_fork )
+    {
+      // required for signal handling to work
+      declare(ticks=1);
+      
+      // trap SIGTERM
+      pcntl_signal(SIGUSR2, array(&$this, '_ipc_event'));
+      
+      if ( !($sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP)) )
+      {
+        throw new Exception("Could not set up private IPC socket. Reason: " . socket_strerror(socket_last_error()));
+      }
+      
+      $this->parent_sock =& $sockets[0];
+      $this->child_sock =& $sockets[1];
+    }
+    
     while ( true )
     {
       // if this is a child process, we're finished - close up shop
@@ -312,8 +350,13 @@
       {
         if ( function_exists('status') )
           status('Exiting child process');
-        @socket_shutdown($remote);
-        @socket_close($remote);
+        
+        $remote->destroy();
+        
+        // let the parent know that we're out of here
+        $this->send_ipc_event("exit " . getmypid());
+        
+        // bye
         exit(0);
       }
       
@@ -321,19 +364,9 @@
       // trick from http://us.php.net/manual/en/function.socket-accept.php
       if ( !defined('HTTPD_WS_CHILD') )
       {
-        $remote = false;
-        $timeout = 5;
-        switch(@socket_select($r = array($this->sock), $w = array($this->sock), $e = array($this->sock), $timeout)) {
-          case 2:
-            break;
-          case 1:
-            $remote = @socket_accept($this->sock);
-            break;
-          case 0:
-            break;
-        }
+        $remote = $this->server->accept();
       }
-         
+      
       if ( !$remote )
       {
         $this->in_keepalive = false;
@@ -351,7 +384,7 @@
         else if ( $pid )
         {
           // we are the parent, continue listening
-          socket_close($remote);
+          $remote->soft_shutdown();
           $this->child_list[] = $pid;
           continue;
         }
@@ -359,8 +392,16 @@
         {
           // this is the child
           define('HTTPD_WS_CHILD', 1);
-          @socket_set_option($this->sock, SOL_SOCKET, SO_REUSEADDR, 1);
-          socket_close($this->sock);
+          
+          // setup to handle signals
+          if ( function_exists('pcntl_signal') )
+          {
+            // required for signal handling to work
+            declare(ticks=1);
+            
+            // trap SIGTERM
+            pcntl_signal(SIGUSR2, array(&$this, '_ipc_event'));
+          }
         }
       }
       
@@ -368,39 +409,77 @@
       $this->headers_sent = false;
       $this->in_scriptlet = false;
       
-      // read request
+      //
+      // READ THE REQUEST
+      //
+      
+      // this is a complicated situation because we need to keep enough ticks going to properly handle
+      // signals, meaning we can't use stream_set_timeout() and instead need to rely on our own timing
+      // logic. setting the timeout to a short period, say 200,000 usec, we can minimize CPU usage and
+      // have a good response time.
+      
+      $remote->set_timeout(0, 200000);
+      $start_time = microtime(true);
+      $client_headers = '';
+      $last_line = '';
+      while ( true )
+      {
+        if ( $start_time + HTTPD_KEEP_ALIVE_TIMEOUT < microtime(true) || $remote->is_eof() )
+        {
+          // request expired -- end the process here
+          if ( !defined('HTTPD_WS_CHILD') )
+            $remote->destroy();
+          
+          continue 2;
+        }
+        $line = strval($remote->read_normal());
+        $line = str_replace("\r", "", $line);
+        
+        // raw layer wants to send 2 empty lines through, stream layer wants to send one.
+        $last_line_check = ( HTTPD_SOCKET_LAYER != 'Raw' || ( HTTPD_SOCKET_LAYER == 'Raw' && $last_line == "\n" ) );
+        if ( $line == "\n" && $last_line_check )
+          // we have two newlines in a row, break out since we have a full request
+          break;
+          
+        $client_headers .= $line;
+        $last_line = $line;
+      }
+      
+      /*
       $last_line = '';
       $client_headers = '';
       if ( defined('HTTPD_WS_CHILD') )
       {
-        @socket_set_timeout($remote, HTTPD_KEEP_ALIVE_TIMEOUT);
+        $remote->set_timeout(1);
       }
-      if ( $line = @socket_read($remote, 1024, PHP_NORMAL_READ) )
+      if ( $line = $remote->read_normal() )
       {
         do
         {
           $line = str_replace("\r", "", $line);
           if ( empty($line) )
             continue;
-          if ( $line == "\n" && $last_line == "\n" )
+          $last_line_check = ( HTTPD_SOCKET_LAYER != 'Raw' || ( HTTPD_SOCKET_LAYER == 'Raw' && $last_line == "\n" ) );
+          if ( $line == "\n" && $last_line_check )
             break;
           $client_headers .= $line;
           $last_line = $line;
+          $line = $remote->read_normal()
         }
-        while ( $line = @socket_read($remote, 1024, PHP_NORMAL_READ) );
+        while ( true );
       }
       else
       {
         if ( defined('HTTPD_WS_CHILD') )
         {
-          $md = @socket_get_status($remote);
-          if ( @$md['timed_out'] )
+          if ( $remote->timed_out() )
           {
             status('[debug] keep-alive connection timed out');
             continue; // will jump back to the start of the loop and kill the child process
           }
         }
       }
+      */
       
       // parse request
       $client_headers = trim($client_headers);
@@ -467,7 +546,7 @@
           $mode = 'data';
           $last_line = '';
           $i = 0;
-          while ( $data = socket_read($remote, 8388608, PHP_NORMAL_READ) )
+          while ( $data = $remote->read_normal(8388608) )
           {
             $data_trim = trim($data, "\r\n");
             if ( $mode != 'data' )
@@ -569,11 +648,11 @@
         {
           if ( isset($_SERVER['HTTP_CONTENT_LENGTH']) )
           {
-            $postdata = socket_read($remote, intval($_SERVER['HTTP_CONTENT_LENGTH']), PHP_BINARY_READ);
+            $postdata = $remote->read_binary(intval($_SERVER['HTTP_CONTENT_LENGTH']));
           }
           else
           {
-            $postdata = socket_read($remote, 8388608, PHP_NORMAL_READ);
+            $postdata = $remote->read_normal(8388608);
           }
           if ( preg_match_all('/(^|&)([a-z0-9_\.\[\]-]+)(=[^ &]+)?/', $postdata, $matches) )
           {
@@ -601,7 +680,7 @@
       $_SERVER['REQUEST_METHOD'] = $method;
       
       // get remote IP and port
-      socket_getpeername($remote, $_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_PORT']);
+      $remote->get_peer_info($_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_PORT']);
       
       $_GET = array();
       if ( preg_match_all('/(^|&)([a-z0-9_\.\[\]-]+)(=[^ &]+)?/', $params, $matches) )
@@ -691,25 +770,20 @@
       
       if ( !$this->in_keepalive && defined('HTTPD_WS_CHILD') )
       {
-        // if ( defined('HTTPD_WS_CHILD') )
-        //   status('Closing connection');
-        @socket_shutdown($remote);
-        @socket_close($remote);
-        if ( function_exists('status') )
-          status('Exiting child process');
-        exit(0);
+        // connection: close
+        // continue on to the shutdown handler
+        continue;
       }
       else if ( defined('HTTPD_WS_CHILD') )
       {
         // if ( defined('HTTPD_WS_CHILD') )
         //   status('Continuing connection');
-        // @socket_write($remote, "\r\n\r\n");
+        // $remote->write("\r\n\r\n");
         $last_finish_time = microtime(true);
       }
       else
       {
-        @socket_shutdown($remote);
-        @socket_close($remote);
+        $remote->destroy();
       }
     }
   }
@@ -743,15 +817,15 @@
     $headers = preg_replace("#[\r\n]+$#", '', $headers);
     $connection = ( $this->in_keepalive ) ? 'keep-alive' : 'close';
     
-    @socket_write($socket, "HTTP/1.1 $http_code $reason_code\r\n");
-    @socket_write($socket, "Server: $this->server_string");
-    @socket_write($socket, "Connection: $connection\r\n");
-    @socket_write($socket, "Content-Type: $contenttype\r\n");
+    $socket->write("HTTP/1.1 $http_code $reason_code\r\n");
+    $socket->write("Server: $this->server_string");
+    $socket->write("Connection: $connection\r\n");
+    $socket->write("Content-Type: $contenttype\r\n");
     if ( !empty($headers) )
     {
-      @socket_write($socket, "$headers\r\n");
+      $socket->write("$headers\r\n");
     }
-    @socket_write($socket, "\r\n");
+    $socket->write("\r\n");
   }
   
   /**
@@ -831,7 +905,7 @@
             $sz = strlen($contents);
             $this->send_client_headers($socket, 200, 'text/html', "Content-length: $sz\r\n");
             
-            @socket_write($socket, $contents);
+            $socket->write($contents);
             
             return true;
           }
@@ -858,7 +932,7 @@
           // send body
           while ( $blk = @fread($fh, 768000) )
           {
-            @socket_write($socket, $blk);
+            $socket->write($blk);
           }
           fclose($fh);
           return true;
@@ -909,7 +983,7 @@
           // send body
           while ( $blk = @fread($fh, 768000) )
           {
-            @socket_write($socket, $blk);
+            $socket->write($blk);
           }
           fclose($fh);
           return true;
@@ -983,7 +1057,7 @@
         // $output = dechex(strlen($output)) . "\r\n$output";
         
         // write body
-        @socket_write($socket, $output);
+        $socket->write($output);
         
         $this->headers_sent = false;
         
@@ -1056,7 +1130,7 @@
           $lm_date = date('r', filemtime(__FILE__));
           $size = strlen($image_data);
           $this->send_client_headers($socket, 200, $type, "Last-Modified: $lm_date\r\nContent-Length: $size");
-          @socket_write($socket, $image_data);
+          $socket->write($image_data);
         }
         else
         {
@@ -1126,7 +1200,7 @@
         $this->send_client_headers($socket, 200, 'text/html', "Content-Length: $len");
         
         // write to the socket
-        @socket_write($socket, $contents);
+        $socket->write($contents);
         
         return true;
         break;
@@ -1191,7 +1265,7 @@
       $headers = 'Content-length: ' . strlen($html);
       
     $this->send_client_headers($socket, $http_code, 'text/html', $headers);
-    @socket_write($socket, $html);
+    $socket->write($html);
   } 
   
   /**
@@ -1528,6 +1602,286 @@
     return $array;
   }
   
+  /**
+   * Handle an IPC event. Called only upon SIGUSR2.
+   */
+  
+  function _ipc_event()
+  {
+    $pid = getmypid() . ':' . $this->parent_pid;
+    
+    // decide which socket to use
+    if ( defined('HTTPD_WS_CHILD') )
+      $sock =& $this->parent_sock;
+    else
+      $sock =& $this->child_sock;
+    
+    // try to read the event
+    // this sometimes gets hung up because socket_set_timeout() doesn't seem to work on its own set of
+    // functions (it only works on PHP's normal streams)
+    if ( $line = @fgets($sock, 1024) )
+    {
+      $line = trim($line);
+      list($action, $param) = explode(' ', $line);
+      switch($action)
+      {
+        case 'exit':
+          // this is to prevent zombie children
+          pcntl_waitpid(intval($param), $status);
+          // we know this child is dead now, remove them from the list
+          foreach ( $this->child_list as $i => $pid )
+          {
+            if ( $pid === intval($param) )
+            {
+              unset($this->child_list[$i]);
+              $this->child_list = array_values($this->child_list);
+              break;
+            }
+          }
+          break;
+        case 'die':
+          // only do this if this is a child (both security and design)
+          if ( defined('HTTPD_WS_CHILD') )
+          {
+            if ( function_exists('status') )
+            {
+              status('Received shutdown request, complying');
+            }
+            $this->send_ipc_event("exit " . getmypid());
+            exit(0);
+          }
+          break;
+        default:
+          break;
+      }
+    }
+  }
+  
+  /**
+   * Send an IPC event.
+   * @param string Data to write to the socket, newline will be added automatically
+   */
+  
+  function send_ipc_event($data)
+  {
+    if ( defined('HTTPD_WS_CHILD') )
+      $sock =& $this->parent_sock;
+    else
+      $sock =& $this->child_sock;
+      
+    $data = rtrim($data, "\r\n") . "\n";
+    @fwrite($sock, $data);
+    
+    // if we're a child, signal the parent
+    if ( defined('HTTPD_WS_CHILD') )
+    {
+      posix_kill($this->parent_pid, SIGUSR2);
+    }
+    // if we're the parent, signal all children
+    else
+    {
+      foreach ( $this->child_list as $pid )
+      {
+        posix_kill($pid, SIGUSR2);
+      }
+    }
+  }
+  
+}
+
+/**
+ * Socket abstraction layer - low-level socket functions (socket_*)
+ */
+
+class Socket_Raw
+{
+  var $sock;
+  var $socket_initted = false;
+  
+  function tcp_listen($address, $port)
+  {
+    $this->sock = @socket_create(AF_INET, SOCK_STREAM, getprotobyname('tcp'));
+    if ( !$this->sock )
+      throw new Exception('Could not create socket');
+    $result = @socket_bind($this->sock, $address, $port);
+    if ( !$result )
+      throw new Exception("Could not bind to $address:$port");
+    $this->socket_initted = true;
+    $result = @socket_listen($this->sock, SOMAXCONN);
+    if ( !$result )
+      throw new Exception("Could not listen for connections $address:$port");
+    
+    $this->socket_initted = true;
+  }
+  
+  function destroy()
+  {
+    if ( $this->socket_initted )
+    {
+      // http://us3.php.net/manual/en/function.socket-bind.php
+      if ( !@socket_set_option($this->sock, SOL_SOCKET, SO_REUSEADDR, 1) )
+      {
+        echo socket_strerror(socket_last_error($this->sock)) . "\n";
+      }
+      @socket_shutdown($this->sock, 2);
+      @socket_close($this->sock);
+    }
+  }
+  
+  function accept()
+  {
+    $remote = false;
+    $timeout = 5;
+    switch(@socket_select($r = array($this->sock), $w = array($this->sock), $e = array($this->sock), $timeout)) {
+      case 2:
+        return false;
+      case 1:
+        $remote = @socket_accept($this->sock);
+        $return = new Socket_Raw();
+        $return->sock = $remote;
+        $return->socket_initted = true;
+        return $return;
+        break;
+      case 0:
+        return false;
+    }
+  }
+  
+  /**
+   * Closes the socket but doesn't destroy it.
+   */
+  
+  function soft_shutdown()
+  {
+    @socket_set_option($this->sock, SOL_SOCKET, SO_REUSEADDR, 1);
+    socket_close($this->sock);
+  }
+  
+  function set_timeout($timeout, $usec = false)
+  {
+    // doesn't work in this.
+  }
+  
+  function read_normal($length = 1024)
+  {
+    return @socket_read($this->sock, $length, PHP_NORMAL_READ);
+  }
+  
+  function read_binary($length = 1024)
+  {
+    return @socket_read($this->sock, $length, PHP_BINARY_READ);
+  }
+  
+  function timed_out()
+  {
+    $md = @socket_get_status($this->sock);
+    return ( isset($md['timed_out']) ) ? $md['timed_out'] : false;
+  }
+  
+  function get_peer_info(&$addr, &$port)
+  {
+    socket_getpeername($this->sock, $addr, $port);
+  }
+  
+  function write($data)
+  {
+    return @socket_write($this->sock, $data);
+  }
+  
+  function is_eof()
+  {
+    // feof() not supported
+    return false;
+  }
+}
+
+/**
+ * Socket abstraction layer - PHP stream support
+ */
+
+class Socket_Stream
+{
+  var $sock;
+  var $socket_initted = false;
+  
+  function tcp_listen($address, $port)
+  {
+    $this->sock = @stream_socket_server("tcp://$address:$port", $errno, $errstr);
+    if ( !$this->sock )
+      throw new Exception("Could not create the socket: error $errno: $errstr");
+  }
+  
+  function destroy()
+  {
+    if ( $this->socket_initted )
+    {
+      // PHP >= 5.2.1
+      if ( function_exists('stream_socket_shutdown') )
+      {
+        @stream_socket_shutdown($this->sock, STREAM_SHUT_RDWR);
+      }
+      fclose($this->sock);
+    }
+  }
+  
+  function accept()
+  {
+    // the goal of a custom accept() with *_select() is to tick every 5 seconds to allow signals.
+    stream_set_blocking($this->sock, 1);
+    $timeout = 5;
+    $selection = @stream_select($r = array($this->sock), $w = array($this->sock), $e = array($this->sock), $timeout);
+    if ( !$selection )
+    {
+      return false;
+    }
+    $remote = stream_socket_accept($this->sock);
+    $return = new Socket_Stream();
+    $return->sock = $remote;
+    $return->socket_initted = true;
+    return $return;
+  }
+  
+  function soft_shutdown()
+  {
+    fclose($this->sock);
+  }
+  
+  function set_timeout($timeout, $usec = false)
+  {
+    return ( $usec ) ? @stream_set_timeout($this->sock, 0, $usec) : @stream_set_timeout($this->sock, $timeout);
+  }
+  
+  function read_normal($length = 1024)
+  {
+    return @fgets($this->sock, $length);
+  }
+  
+  function read_binary($length = 1024)
+  {
+    return @fread($this->sock, $length);
+  }
+  
+  function timed_out()
+  {
+    $md = @stream_get_meta_data($this->sock);
+    return ( isset($md['timed_out']) ) ? $md['timed_out'] : false;
+  }
+  
+  function get_peer_info(&$addr, &$port)
+  {
+    $peer = stream_socket_get_name($this->sock, true);
+    list($addr, $port) = explode(':', $peer);
+  }
+  
+  function write($data)
+  {
+    return @fwrite($this->sock, $data);
+  }
+  
+  function is_eof()
+  {
+    return feof($this->sock);
+  }
 }
 
 /**