2

I have a site which has a feature that causes Apache to route all requests for files in a particular directory through a PHP script via .htaccess. A problem has come up that I'm not sure how to troubleshoot.

There is an online course which has a video on each page, and the pages are loaded via ajax. With the above feature disabled, where Apache directly serves the video, the user can press the Next button while the video is still being downloaded and the next page will load immediately. With the feature enabled, if they press the Next button before the video has finished downloading then the browser will basically hang until the download finishes, and then the next page will load. I'm having a hard time figuring out why this happens, is there something about invoking PHP that would make the browser wait for the entire file to finish?

These are the response headers for a direct request:

HTTP/1.1 206 Partial Content
Date: Fri, 31 Oct 2014 21:28:10 GMT
Server: Apache
Last-Modified: Mon, 20 Oct 2014 15:45:20 GMT
Etag: "1601ee2-c4fcc5-505dc9a1e4fe5"
Accept-Ranges: bytes
Content-Length: 12909765
Vary: User-Agent
Content-Range: bytes 0-12909764/12909765
Keep-Alive: timeout=15, max=142
Connection: Keep-Alive
Content-Type: video/mp4

And for the request that goes through PHP:

HTTP/1.1 206 Partial Content
Date: Fri, 31 Oct 2014 19:11:31 GMT
Server: Apache
P3P: CP="OTI DSP COR CURa ADMa HISa OUR IND STA"
X-Lms: auth
Last-Modified: Mon, 20 Oct 2014 15:45:20 GMT
Etag: "1601ee2-c4fcc5-505dc9a1e4fe5"
Accept-Ranges: bytes
Content-Length: 12909765
Vary: User-Agent
Content-Range: bytes 0-12909764/12909765
Keep-Alive: timeout=15, max=149
Connection: Keep-Alive
Content-Type: video/mp4

Not shown in the list of headers for PHP is that I send blank headers for cache-control, pragma, and expires to override the default set of headers that would disable caching for PHP responses, i.e.:

header('Cache-Control: ');
header('Pragma: ');
header('Expires: ');

I see the same behavior in Firefox and Chrome, I haven't tested with other browsers yet. Chrome is particularly bad, the entire tab becomes unresponsive. If I switch to another tab and back again, it will just be blank white. I cannot close the tab nor the browser unless I use Chrome's task manager to kill the process for that tab. It gets responsive again once the transfer finishes.

Does anyone know what might cause this? The server is running Apache 2.2.26 and PHP 5.4.22. In WHM I have "dso" selected as the PHP 5 handler, and suEXEC is enabled.

Edit:

I've replaced my single-read statement with a loop to check for the client aborting, or the stream timing out, and that hasn't fixed the issue. I'm pasting below the portion of the PHP code that deals with actually sending the file out (as opposed to responses for 304, 403, 404, etc):

(sorry about the formatting, I'm not sure why it added so many extra lines and broke up highlighting)

// file not cached or cache outdated, we respond '200 OK' (or 206) and
// output the file.
if (!isset($headers['Range'])) {
  $response_code = 200;
}
else {
  $response_code = 206;
}

// send Last-Modified header, and set response code
header('Last-Modified: '.gmdate('D, d M Y H:i:s', $mtime).' GMT',
  true, $response_code);
header('Content-Type: ' . get_mime_type($file));
if ($method == 'GET' || $method == 'HEAD') {
  header('Accept-Ranges: bytes');
}

$filesize = filesize($file);
$start_read_pos = 0;
$max_read_chunk = 65536;
$total_read_size = $filesize;
$content_length = $filesize;

// adjust content length and read position based on the range
if (isset($headers['Range'])) {
  // Range: bytes=1000-1999
  // Range: bytes=2000-
  $range_chunks = explode('=', $headers['Range']);
  // the first element should be "bytes"
  $range = $range_chunks[1]; // e.g. 1024-2048, or 1024-
  $range_chunks = explode('-', $range);
  $start_read_pos = intval($range_chunks[0]);
  $end_read_pos = isset($range_chunks[1]) ? intval($range_chunks[1]) : 0;
  if ($end_read_pos == 0) {
    $end_read_pos = $filesize - 1;
  }
  $total_read_size = $end_read_pos - $start_read_pos + 1;
  $content_length = $total_read_size;
}

header('Content-Length: ' . $content_length);
if (isset($headers['Range'])) {
  header('Content-Range: bytes ' . $start_read_pos . '-' . 
    $end_read_pos . '/' . $filesize);
}

// only output the content if this is not a head request
if ($method != 'HEAD') {

  // read the file data

  if ($ext != 'php') {
    $fh = fopen($file, 'rb');
    if ($fh) {

      if ($start_read_pos != 0) {
        // discard $start_read_pos many bytes
        fread($fh, $start_read_pos);
      }

      $total_sent = 0;
      $read_chunk = $max_read_chunk;
      $timeout = false;
      while(!connection_aborted() &&
            !feof($fh) &&
            !$timeout &&
            $total_sent < $total_read_size) {
        // if the next (i.e. last) read requires fewer 
        // than the max read chunk
        if ($total_sent + $max_read_chunk > $total_read_size) {
          $read_chunk = $total_read_size - $total_sent;
        }
        echo fread($fh, $read_chunk);
        $total_sent += $read_chunk;
        $stream_info = stream_get_meta_data($fh);
        if ($stream_info['timed_out']) {
          $timeout = true;
        }
      }

      fclose($fh);
    }
  }
  else {
    // don't show the PHP code
    // pad to match the content length 
    echo str_pad('PHP code', $content_length);header
  }

  exit();
}
  • Are you serving the video file itself through PHP, or just the page that contains it? – Tim Fountain Oct 31 '14 at 23:32
  • The file itself. I have a rule in .htaccess to route all requests for files in a certain directory to the PHP script. It performs some authentication and supports some other features of the site. The protected files are uploaded by admins through the site. The script is using fread to send out the file data. I can post the full PHP script but it's a little over 200 lines. In this case the files that contain the videos are static .html files, although their data is also being sent through the PHP script. – Steve Oct 31 '14 at 23:44
  • Does the PHP script include code to handle HTTP range headers? – Tim Fountain Oct 31 '14 at 23:51
  • It does, I've just added it last week thinking that lack of support for range headers was the problem, but that didn't fix the issue. I've done some testing and it looks like it is responding correctly to the range requests. After putting it live the videos are still playing like they were before, I'm not seeing any corruption that may be the result of an incorrect implementation with the range. It does respond with the 206 for range requests also. – Steve Nov 3 '14 at 16:17
  • @Steve for formatting source code on webmasters stack exchange select the code lines and then use the {} button instead of adding <code></code> elements. – richhallstoke Nov 4 '14 at 10:01
2

The range header handling code is probably where the issue lies, although it's hard to say for sure. Serving videos with PHP tends not to be very memory efficient, particularly if they are large. I'd suggest you use something like X-Sendfile instead. That way, you still do your authentication checks in PHP, but you get your web server to do the heavy lifting for the actual file serving.

  • X-Sendfile looks interesting, I would prefer to move most of the file handling to Apache. I'll look into this further. – Steve Nov 3 '14 at 17:26
  • This might be my only solution, PHP doesn't seem like it wants to release the connection. Probably better to let Apache handle that, since it seems to do it fine. I'll need to look into creating a WHM/cPanel opt mod for X-Sendfile so that it doesn't get replaced when I update Apache. – Steve Nov 3 '14 at 21:30
  • I'll go ahead and accept this answer, it doesn't look like I'm going to be able to find a solution for this in PHP. I'll put together an opt mod for X-Sendfile and then rewrite my code to let Apache handle actually serving the file. – Steve Nov 4 '14 at 16:27
1

I believe the problem here is not likely to be related to your Apache configuration but in your PHP script. For example, if your script loads the entire video file in one go, and then sends it all to the browser in one go this can cause this issue:

<?php
header( 'Content-Type: video/mp4' );
$sVideo = @file_get_contents( '/var/www/www.example.com/videos/video.mp4' );
echo( $sVideo );
exit;

However, if instead you loaded the video file and sent it back to the browser in a series of much smaller chunks of data, then this may well solve your issue:

<?php
header( 'Content-Type: video/mp4' );
$hVideo = fopen( '/var/www/www.example.com/videos/video.mp4', 'rb' );
while( !feof( $hVideo )) {
  echo( fread( $hVideo, 4096 ));
  $aStreamInfo = stream_get_meta_data( $hVideo );
  if( $aStreamInfo['timed_out'] ) break;
}
fclose( $hVideo );
exit;
  • I do have a single read statement, although I'm using fread rather than file_get_contents (since the range header might specify a start position in the middle of the file). I was considering using a read loop with smaller chunks and using connection_aborted to determine if the client has canceled the request, I'll look into this as a solution as well. I wanted to keep the number of reads to a minimum since this code runs for every single file that gets requested out of that directory, but this might be a case of pre-optimization gone wrong. – Steve Nov 3 '14 at 17:29
  • I've edited my question to add some code. After adding a loop to check for client aborts or stream timeouts, it looks like the issue is still there. – Steve Nov 3 '14 at 21:29
  • '// discard $start_read_pos many bytes' is a bad idea, as PHP still has to read in the data. You should use fseek() instead. By I'd still go with X-Sendfile if you're able to get that working. – Tim Fountain Nov 3 '14 at 22:10

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.