Skip to content

iOS Safari aborts audio/video playback part-way through (range response body exceeds Content-Length) #667

Description

@itlange-jl

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:

  1. 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 -.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions