Index: includes/common.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/common.inc,v
retrieving revision 1.917
diff -u -9 -p -r1.917 common.inc
--- includes/common.inc	3 Jun 2009 02:50:21 -0000	1.917
+++ includes/common.inc	6 Jun 2009 11:29:17 -0000
@@ -50,18 +50,24 @@ define('JS_LIBRARY', -100);
  */
 define('JS_DEFAULT', 0);
 
 /**
  * The weight of theme JavaScript code being added to the page.
  */
 define('JS_THEME', 100);
 
 /**
+ * Error code indicating that the request made by drupal_http_request() exceeded
+ * the specified timeout.
+ */
+define('HTTP_REQUEST_TIMEOUT', 1);
+
+/**
  * Add content to a specified region.
  *
  * @param $region
  *   Page region the content is added to.
  * @param $data
  *   Content to be added.
  */
 function drupal_add_region_content($region = NULL, $data = NULL) {
   static $content = array();
@@ -416,18 +422,22 @@ function drupal_access_denied() {
  *   - headers
  *       An array containing request headers to send as name/value pairs.
  *   - method
  *       A string containing the request method. Defaults to 'GET'.
  *   - data
  *       A string containing the request body. Defaults to NULL.
  *   - max_redirects
  *       An integer representing how many times a redirect may be followed.
  *       Defaults to 3.
+ *   - timeout
+ *       A float representing the maximum number of seconds the function call
+ *       may take. The default is 30 seconds. If a timeout occurs, the error
+ *       code is set to the HTTP_REQUEST_TIMEOUT constant.
  * @return
  *   An object which can have one or more of the following parameters:
  *   - request
  *       A string containing the request body that was sent.
  *   - code
  *       An integer containing the response status code, or the error code if
  *       an error occurred.
  *   - protocol
  *       The response protocol (e.g. HTTP/1.1 or HTTP/1.0).
@@ -456,29 +466,31 @@ function drupal_http_request($url, array
     $result->error = 'unable to parse URL';
     return $result;
   }
 
   if (!isset($uri['scheme'])) {
     $result->error = 'missing schema';
     return $result;
   }
 
+  timer_start(__FUNCTION__);
+
   switch ($uri['scheme']) {
     case 'http':
       $port = isset($uri['port']) ? $uri['port'] : 80;
       $host = $uri['host'] . ($port != 80 ? ':' . $port : '');
-      $fp = @fsockopen($uri['host'], $port, $errno, $errstr, 15);
+      $fp = @fsockopen($uri['host'], $port, $errno, $errstr, $options['timeout']);
       break;
     case 'https':
       // Note: Only works when PHP is compiled with OpenSSL support.
       $port = isset($uri['port']) ? $uri['port'] : 443;
       $host = $uri['host'] . ($port != 443 ? ':' . $port : '');
-      $fp = @fsockopen('ssl://' . $uri['host'], $port, $errno, $errstr, 20);
+      $fp = @fsockopen('ssl://' . $uri['host'], $port, $errno, $errstr, $options['timeout']);
       break;
     default:
       $result->error = 'invalid schema ' . $uri['scheme'];
       return $result;
   }
 
   // Make sure the socket opened properly.
   if (!$fp) {
     // When a network error occurs, we use a negative number so it does not
@@ -501,18 +513,19 @@ function drupal_http_request($url, array
     $path .= '?' . $uri['query'];
   }
 
   // Merge the default options.
   $options += array(
     'headers' => array(),
     'method' => 'GET',
     'data' => NULL,
     'max_redirects' => 3,
+    'timeout' => 30,
   );
 
   // Merge the default headers.
   $options['headers'] += array(
     'User-Agent' => 'Drupal (+http://drupal.org/)',
   );
 
   // RFC 2616: "non-standard ports MUST, default ports MAY be included".
   // We don't add the standard port to prevent from breaking rewrite rules
@@ -547,20 +560,28 @@ function drupal_http_request($url, array
     $request .= $name . ': ' . trim($value) . "\r\n";
   }
   $request .= "\r\n" . $options['data'];
   $result->request = $request;
 
   fwrite($fp, $request);
 
   // Fetch response.
   $response = '';
-  while (!feof($fp) && $chunk = fread($fp, 1024)) {
-    $response .= $chunk;
+  while (!feof($fp)) {
+    // Calculate how much time is left of the original timeout value.
+    $timeout = $options['timeout'] - timer_read(__FUNCTION__) / 1000;
+    if ($timeout <= 0) {
+      $result->code = HTTP_REQUEST_TIMEOUT;
+      $result->error = 'request timed out';
+      return $result;
+    }
+    stream_set_timeout($fp, floor($timeout), floor(1000000 * fmod($timeout, 1)));
+    $response .= fread($fp, 1024);
   }
   fclose($fp);
 
   // Parse response headers from the response body.
   list($response, $result->data) = explode("\r\n\r\n", $response, 2);
   $response = preg_split("/\r\n|\n|\r/", $response);
 
   // Parse the response status line.
   list($protocol, $code, $status_message) = explode(' ', trim(array_shift($response)), 3);
@@ -633,19 +654,24 @@ function drupal_http_request($url, array
 
   switch ($code) {
     case 200: // OK
     case 304: // Not modified
       break;
     case 301: // Moved permanently
     case 302: // Moved temporarily
     case 307: // Moved temporarily
       $location = $result->headers['Location'];
-      if ($options['max_redirects']) {
+      $options['timeout'] -= timer_read(__FUNCTION__) / 1000;
+      if ($options['timeout'] <= 0) {
+        $result->code = HTTP_REQUEST_TIMEOUT;
+        $result->error = 'request timed out';
+      }
+      elseif ($options['max_redirects']) {
         // Redirect to the new location.
         $options['max_redirects']--;
         $result = drupal_http_request($location, $options);
         $result->redirect_code = $code;
       }
       $result->redirect_url = $location;
       break;
     default:
       $result->error = $status_message;
Index: modules/simpletest/tests/common.test
===================================================================
RCS file: /cvs/drupal/drupal/modules/simpletest/tests/common.test,v
retrieving revision 1.44
diff -u -9 -p -r1.44 common.test
--- modules/simpletest/tests/common.test	2 Jun 2009 03:33:36 -0000	1.44
+++ modules/simpletest/tests/common.test	6 Jun 2009 11:29:17 -0000
@@ -303,18 +303,31 @@ class DrupalHTTPRequestTestCase extends 
     $this->assertEqual($result->code, 200, t('Fetched page successfully.'));
     $this->drupalSetContent($result->data);
     $this->assertTitle(t('Welcome to @site-name | @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), t('Site title matches.'));
 
     // Test that code and status message is returned.
     $result = drupal_http_request(url('pagedoesnotexist', array('absolute' => TRUE)));
     $this->assertTrue(!empty($result->protocol),  t('Result protocol is returned.'));
     $this->assertEqual($result->code, '404', t('Result code is 404'));
     $this->assertEqual($result->status_message, 'Not Found', t('Result status message is "Not Found"'));
+
+    // Test that timeout is respected. The test machine is expected to be able
+    // to make the connection (i.e. complete the fsockopen()) in 2 seconds and
+    // return within a total of 5 seconds. If the test machine is extremely
+    // slow, the test will fail. fsockopen() has been seen to time out in
+    // slightly less than the specified timeout, so allow a little slack on the
+    // minimum expected time (i.e. 1.8 instead of 2).
+    timer_start(__METHOD__);
+    $result = drupal_http_request(url('system-test/sleep/10', array('absolute' => TRUE)), array('timeout' => 2));
+    $time = timer_read(__METHOD__) / 1000;
+    $this->assertTrue(1.8 < $time && $time < 5, t('Request timed out (%time seconds).', array('%time' => $time)));
+    $this->assertTrue($result->error, t('An error message was returned.'));
+    $this->assertEqual($result->code, HTTP_REQUEST_TIMEOUT, t('Proper error code was returned.'));
   }
 
   function testDrupalHTTPRequestBasicAuth() {
     $username = $this->randomName();
     $password = $this->randomName();
     $url = url('system-test/auth', array('absolute' => TRUE));
 
     $auth = str_replace('http://', 'http://' . $username . ':' . $password . '@', $url);
     $result = drupal_http_request($auth);
Index: modules/simpletest/tests/system_test.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/simpletest/tests/system_test.module,v
retrieving revision 1.11
diff -u -9 -p -r1.11 system_test.module
--- modules/simpletest/tests/system_test.module	27 May 2009 18:34:00 -0000	1.11
+++ modules/simpletest/tests/system_test.module	6 Jun 2009 11:29:17 -0000
@@ -1,16 +1,22 @@
 <?php
 // $Id: system_test.module,v 1.11 2009/05/27 18:34:00 dries Exp $
 
 /**
  * Implement hook_menu().
  */
 function system_test_menu() {
+  $items['system-test/sleep/%'] = array(
+    'page callback' => 'system_test_sleep',
+    'page arguments' => array(2),
+    'access callback' => TRUE,
+    'type' => MENU_CALLBACK,
+  );
   $items['system-test/auth'] = array(
     'page callback' => 'system_test_basic_auth_page',
     'access callback' => TRUE,
     'type' => MENU_CALLBACK,
   );
   $items['system-test/redirect/%'] = array(
     'title' => 'Redirect',
     'page callback' => 'system_test_redirect',
     'page arguments' => array(2),
@@ -50,18 +56,22 @@ function system_test_menu() {
     'page callback' => 'variable_get',
     'page arguments' => array('simpletest_bootstrap_variable_test', NULL),
     'access arguments' => array('access content'),
     'type' => MENU_CALLBACK,
   );
 
   return $items;
 }
 
+function system_test_sleep($seconds) {
+  sleep($seconds);
+}
+
 function system_test_basic_auth_page() {
   $output = t('$_SERVER[\'PHP_AUTH_USER\'] is @username.', array('@username' => $_SERVER['PHP_AUTH_USER']));
   $output .= t('$_SERVER[\'PHP_AUTH_PW\'] is @password.', array('@password' => $_SERVER['PHP_AUTH_PW']));
   return $output;
 }
 
 function system_test_redirect($code) {
   $code = (int)$code;
   if ($code != 200) {
