206 Partial Content returned with Content-Length: 0

apache-2.2php5

I'm serving large audio files on an Apache server using mod_xsendfile (version >= 0.10). The files are served fine when I use header( 'HTTP/1.1 200 OK' ); However, those files are served in full. Because I want to allow visitors to seek in the audio files, I am accepting Range requests from the clients.

This is where I run into problems. When I use header( 'HTTP/1.1 206 Partial Content' );, the PHP script responds with Content-Length: 0. This is odd to me, because in the Content-Range response, the range of the request and the total file size of the file is mentioned correctly. For example:

Content-Length:0
Content-Range:bytes 0-139143856/139143857

The headers used by my PHP script to send these two headers are:

$filesize = filesize( $mix_file );

...

$start = calculated from HTTP_RANGE or 0;
$end   = calculated from HTTP_RANGE or $filesize - 1;

...

header( 'Content-Length: ' . ( ( $end - $start ) + 1 ) );
header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $filesize );

The Content-Range header is only sent if there was a Range request, otherwise my script omits this header.

Why does Content-Length return 0 when I've explicitly set its value?

Things I've tried for troubleshooting

  • Load stream.php?id=9966 with a Range request, which is the intended setup. Script returns 206 Partial Content four times (see screenshot and intended script below). All of them have Content-Length set incorrectly as 0.
  • Load stream.php?id=9966 directly in browser. Causes GET to be sent without a Range request. Script returns 200 OK with the entire file contents. Content-Length is returned correctly. File starts downloading in browser window.
  • Do not set 206 Partial Content header in script in case of a Range request. Causes script to return 200 OK header. Content-Length is correctly returned, as is Content-Range.
  • Force stream.php to always return 200 OK, even when returning a Content-Range response. When GETting with a Range request, both Content-Length and Content-Range are returned correctly.
  • Previous two setups do not allow seeking to a non-buffered position in the audio, which is a requirement. In both test cases, the file content is sent in its entirety and any attempt to seek to a non-buffered position cause the audio to cut out until the download of the file has "caught up".

206 headers returned

Request

GET /stream.php?id=9966 HTTP/1.1
Host: next.tjoonz.com
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept-Encoding: identity;q=1, *;q=0
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.134 Safari/537.36
Accept: */*
Referer: http://next.tjoonz.com/
Accept-Language: en-US,en;q=0.8,nl;q=0.6
Cookie: __cfduid=d2623ed31d1d855be05395a7fbcf76c311425543933; __uvt=; uvts=3EmUJ804REmm3E0w; wp-settings-1=hidetb%3D1%26editor%3Dhtml%26m10%3Dc%26m8%3Dc%26m5%3Dc%26m0%3Dc%26m9%3Dc%26m6%3Dc%26m3%3Dc%26imgsize%3Dmedium%26align%3Dnone%26m1%3Dc%26m2%3Dc%26m4%3Dc%26m11%3Dc%26m7%3Dc%26wplink%3D1%26urlbutton%3Dfile%26libraryContent%3Dbrowse%26ed_size%3D373%26dfw_width%3D822; wp-settings-time-1=1435585363; _ga=GA1.2.1985480703.1425543937; audio=yes
Range: bytes=0-

Response

HTTP/1.1 206 Partial Content
Date: Tue, 14 Jul 2015 18:39:52 GMT
Server: Apache
Content-Disposition: attachment; filename="sea-monkey-napcast-018.mp3"
Accept-Ranges: bytes
X-SENDFILE: /home/tjoonzvps/audio/sea-monkey-napcast-018.mp3
Set-Cookie: audio=yes; expires=Wed, 15-Jul-2015 18:39:52 GMT; path=/
Content-Length: 0
Content-Range: bytes 0-145036217/145036218
Keep-Alive: timeout=2, max=97
Connection: Keep-Alive
Content-Type: audio/mpeg

PHP script (the relevant part)

if( file_exists( $mix_file ) ) {
    tjnz_increment_plays( $mix_id );

    // get the 'Range' header if one was sent
    if( isset( $_SERVER[ 'HTTP_RANGE' ] ) ) {
        $range = $_SERVER[ 'HTTP_RANGE' ];
    } else {
        $range = false;
    }

    // get the data range requested (if any)
    $filesize = filesize( $mix_file );
    $start = 0;
    $end   = $filesize - 1;
    if( $range ) {
        $partial = true;
        list( $param, $range ) = explode( '=', $range );
        if( strtolower( trim( $param ) ) != 'bytes') {
            header( 'HTTP/1.1 400 Invalid Request' );
            die();
        }
        $range = explode( ',', $range );
        $range = explode( '-', $range[ 0 ] );
        if( count( $range ) != 2 ) {
            header( 'HTTP/1.1 400 Invalid Request' );
            die();
        }
        if ( $range[ 0 ] === '' ) {
            $end   = $filesize - 1;
            $start = $end - intval( $range[ 0 ] );
        } else if( $range[ 1 ] === '' ) {
            $start = intval( $range[ 0 ] );
            $end   = $filesize - 1;
        } else {
            $start = intval( $range[ 0 ] );
            $end   = intval( $range[ 1 ] );
            if( $end >= $filesize || ( !$start && ( !$end || $end == ( $filesize - 1 ) ) ) ) {
                $partial = false;
            }
        }
    } else {
        $partial = false;
    }

    // send standard headers
    header( 'Content-Type: audio/mpeg' );
    header( 'Content-Length: ' . ( ( $end - $start ) + 1 ) );
    header( 'Content-Disposition: attachment; filename="' . $mix_slug . '.mp3"' );
    header( 'Accept-Ranges: bytes' );

    // if requested, send extra headers and part of file...
    if ( $partial ) {
        header( 'HTTP/1.1 206 Partial Content' ); 
        header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $filesize ); 
        header( 'X-SENDFILE: ' . $mix_file );
    } else {
        header( 'X-SENDFILE: ' . $mix_file );
    }
    die();
}

Best Answer

A quick perusal of the mod_sendfile source code reveals that it simply does not support sending partial content. If it gets a response other than 200, it does not send anything, thus your Content-Length gets changed to 0 and no response body is returned.

You can try using X-Accel-Redirect with nginx, which works similarly and does support partial content. Just change "X-Sendfile" to "X-Accel-Redirect" in your code and use nginx instead of Apache. Keep in mind that the directory containing the static files must have a location defined as internal in the nginx configuration. This both enables X-Accel-Redirect and serves 404 errors to anyone who attempts to access the static file directly.

location /audio/ {
    internal;
}