iOS Safari aborts audio/video playback part-way through (range response body exceeds Content-Length)
Summary
When an audio or video file is opened in Safari on iOS/iPadOS, playback stops after roughly the first portion of the file (in my case ~2 minutes of an MP3) instead of playing to the end. The same file plays fully in desktop Chrome and Firefox.
Environment
- XBackBone 3.8.2 (also present on the
3.x branch head)
- Local storage adapter
- Reproduced by opening an inline media link (e.g. shared via a WhatsApp link) in iOS Safari
Root cause
The issue is in MediaController::handlePartialRequest() (app/Controllers/MediaController.php).
The response body is emitted with:
fpassthru($stream->detach());
fpassthru() writes from the current stream position to EOF — it ignores the computed $end of the requested range. The headers, however, announce a bounded length:
header('Content-Length: '.($end - $start + 1));
header("Content-Range: bytes $start-$end/{$stream->getSize()}");
So for any bounded range request (bytes=X-Y with Y before EOF) the body contains more bytes than the advertised Content-Length, which corrupts the response framing on a keep-alive connection.
Why it only breaks Safari:
- Desktop Chrome/Firefox request the whole resource with an open range
Range: bytes=0-. In that path $end ends up at getSize()-1, so Content-Length equals the number of bytes fpassthru() writes — the mismatch does not occur, and playback works.
- iOS Safari issues bounded range requests (starting with a small probe such as
Range: bytes=0-1, then chunked ranges). For bytes=0-1 the server sends Content-Length: 2 but fpassthru() streams the entire file, desyncing the connection. Safari stops after the portion it decoded cleanly.
There are two smaller related issues in the same method:
- Suffix ranges (
bytes=-500, "last 500 bytes") are parsed as start=0, end=500 instead of start=size-500, end=size-1. The if ($range === '-') guard only matches the literal string -.
- The 416 branch sends a malformed header
Content-Range: 0,{size} instead of the RFC 7233 form Content-Range: bytes */{size}.
Suggested fix
The core of the fix is to emit exactly the requested bytes instead of streaming to EOF. I extracted the range maths and the bounded output into a small framework-free helper so the behaviour can be unit-tested in isolation (happy to inline it into the controller instead if you'd prefer to avoid a new class).
New file: app/Web/ByteRange.php
<?php
namespace App\Web;
/**
* Helpers for serving HTTP range ("byte-serving") requests.
*
* Kept free of framework/HTTP dependencies so the range maths and the
* bounded output can be unit tested in isolation.
*/
class ByteRange
{
/**
* Parse a single-range spec (already stripped of the `bytes=` unit, e.g.
* `0-1`, `500-`, `-500`) against a known file size and return the resolved
* [start, end] byte offsets (both inclusive). Returns null when the range
* is unsatisfiable (RFC 7233 => 416).
*
* @return array{0: int, 1: int}|null
*/
public static function parse(string $range, int $size): ?array
{
$range = trim($range);
if ($range === '' || $range === '-' || strpos($range, ',') !== false) {
return null;
}
if ($range[0] === '-') {
// Suffix range: the last N bytes of the file.
$suffix = (int) substr($range, 1);
if ($suffix <= 0) {
return null;
}
$start = max(0, $size - $suffix);
$end = $size - 1;
} else {
$parts = explode('-', $range, 2);
$start = (int) $parts[0];
$end = (isset($parts[1]) && $parts[1] !== '') ? (int) $parts[1] : $size - 1;
}
if ($end > $size - 1) {
$end = $size - 1;
}
if ($start < 0 || $start > $end || $start > $size - 1) {
return null;
}
return [$start, $end];
}
/**
* Write exactly `$length` bytes from `$resource`, starting at offset
* `$start`, to the output stream (or `$output` when provided). Unlike
* fpassthru(), this respects the end of the requested range instead of
* streaming to EOF. The resource is closed before returning.
*
* @return int the number of bytes actually written
*/
public static function stream($resource, int $start, int $length, $output = null, int $bufferSize = 8192): int
{
if (!is_resource($resource)) {
return 0;
}
if (fseek($resource, $start) === -1) {
fclose($resource);
return 0;
}
$written = 0;
while ($length > 0 && !feof($resource)) {
$chunk = fread($resource, min($bufferSize, $length));
if ($chunk === false || $chunk === '') {
break;
}
if ($output === null) {
echo $chunk;
} else {
fwrite($output, $chunk);
}
$read = strlen($chunk);
$written += $read;
$length -= $read;
}
fclose($resource);
return $written;
}
}
app/Controllers/MediaController.php
Add use App\Web\ByteRange;, then:
protected function handlePartialRequest(
Response $response,
Stream $stream,
string $range,
string $disposition,
$media,
$mime
) {
$size = $stream->getSize();
[, $range] = explode('=', $range, 2);
$bounds = ByteRange::parse($range, $size);
// Unsatisfiable or unsupported (e.g. multi-range) request => 416.
if ($bounds === null) {
return $response->withHeader('Content-Type', $mime)
->withHeader('Content-Disposition', $disposition.'; filename="'.$media->filename.'"')
->withHeader('Content-Length', $size)
->withHeader('Accept-Ranges', 'bytes')
->withHeader('Content-Range', "bytes */{$size}")
->withStatus(416)
->withBody($stream);
}
[$start, $end] = $bounds;
$length = $end - $start + 1;
header("Content-Type: $mime");
header('Content-Length: '.$length);
header('Accept-Ranges: bytes');
header("Content-Range: bytes $start-$end/{$size}");
http_response_code(206);
ob_end_clean();
// Emit exactly the requested bytes. Streaming to EOF (as fpassthru does)
// sends more than the advertised Content-Length and breaks iOS Safari
// audio/video playback.
ByteRange::stream($stream->detach(), $start, $length);
exit(0);
}
Test (the regression at the heart of the bug)
Because ByteRange has no framework dependencies, the fix is directly unit-testable — a bounded range must emit exactly Content-Length bytes, never stream to EOF:
public function testStreamEmitsExactlyRequestedBytes(): void
{
$file = tempnam(sys_get_temp_dir(), 'byterange');
file_put_contents($file, str_repeat('X', 100));
// Safari's initial 2-byte probe: Content-Length would be 2.
$out = fopen('php://temp', 'w+b');
$written = \App\Web\ByteRange::stream(fopen($file, 'rb'), 0, 2, $out);
$this->assertSame(2, $written); // old fpassthru() would have written 100
unlink($file);
}
// plus parse() cases: '0-' => [0, 99], '0-1' => [0, 1], '-10' => [90, 99],
// '0-999' => [0, 99] (clamped), '200-300' => null (unsatisfiable).
I've been running this fix in production and iOS Safari now plays MP3s to the end, while desktop browsers are unaffected.
Questions
- Is the
master (4.x) codebase affected by the same Content-Length/body mismatch? The media-serving code there is structured differently, so I couldn't map it 1:1.
- Would you accept a PR? If so, which branch should it target (
3.x, master, or both)? I'm happy to open one with the change above plus the unit test.
iOS Safari aborts audio/video playback part-way through (range response body exceeds Content-Length)
Summary
When an audio or video file is opened in Safari on iOS/iPadOS, playback stops after roughly the first portion of the file (in my case ~2 minutes of an MP3) instead of playing to the end. The same file plays fully in desktop Chrome and Firefox.
Environment
3.xbranch head)Root cause
The issue is in
MediaController::handlePartialRequest()(app/Controllers/MediaController.php).The response body is emitted with:
fpassthru()writes from the current stream position to EOF — it ignores the computed$endof the requested range. The headers, however, announce a bounded length:So for any bounded range request (
bytes=X-YwithYbefore EOF) the body contains more bytes than the advertisedContent-Length, which corrupts the response framing on a keep-alive connection.Why it only breaks Safari:
Range: bytes=0-. In that path$endends up atgetSize()-1, soContent-Lengthequals the number of bytesfpassthru()writes — the mismatch does not occur, and playback works.Range: bytes=0-1, then chunked ranges). Forbytes=0-1the server sendsContent-Length: 2butfpassthru()streams the entire file, desyncing the connection. Safari stops after the portion it decoded cleanly.There are two smaller related issues in the same method:
bytes=-500, "last 500 bytes") are parsed asstart=0, end=500instead ofstart=size-500, end=size-1. Theif ($range === '-')guard only matches the literal string-.Content-Range: 0,{size}instead of the RFC 7233 formContent-Range: bytes */{size}.Suggested fix
The core of the fix is to emit exactly the requested bytes instead of streaming to EOF. I extracted the range maths and the bounded output into a small framework-free helper so the behaviour can be unit-tested in isolation (happy to inline it into the controller instead if you'd prefer to avoid a new class).
New file:
app/Web/ByteRange.phpapp/Controllers/MediaController.phpAdd
use App\Web\ByteRange;, then:Test (the regression at the heart of the bug)
Because
ByteRangehas no framework dependencies, the fix is directly unit-testable — a bounded range must emit exactlyContent-Lengthbytes, never stream to EOF:I've been running this fix in production and iOS Safari now plays MP3s to the end, while desktop browsers are unaffected.
Questions
master(4.x) codebase affected by the sameContent-Length/body mismatch? The media-serving code there is structured differently, so I couldn't map it 1:1.3.x,master, or both)? I'm happy to open one with the change above plus the unit test.