Skip to content

Comments

fix: stream file responses with proper Content-Length header#217

Open
lohanidamodar wants to merge 1 commit into0.33.xfrom
fix-file-response
Open

fix: stream file responses with proper Content-Length header#217
lohanidamodar wants to merge 1 commit into0.33.xfrom
fix-file-response

Conversation

@lohanidamodar
Copy link
Contributor

Summary

This PR fixes file download responses to include a proper Content-Length header when streaming large files. Previously, Swoole's HTTP layer forced Transfer-Encoding: chunked when using $response->write() for streaming, which strips the Content-Length header. This caused two major issues:

  1. No download progress — Browsers cannot show a progress bar without knowing the total size upfront
  2. Client uncertainty — HTTP clients have no way to verify they received the complete file or detect truncated downloads

Problem

When serving file downloads through Swoole, the framework uses $response->write() to stream the body in chunks. However, Swoole's HTTP abstraction automatically switches to chunked transfer encoding when write() is called, removing any Content-Length header that was set. This is a known Swoole behavior — once you call $response->write(), Swoole takes over the transfer encoding.

This means even if you explicitly set Content-Length: 52428800 for a 50MB file, the client receives:

Transfer-Encoding: chunked

...instead of:

Content-Length: 52428800

Solution

New stream() method on base Response

A new stream(callable $reader, int $totalSize) method is added to the abstract Response class (Response.php). It:

  • Accepts a $reader callback fn(int $offset, int $length): string and the total body size
  • Sets Content-Length header, appends cookies/headers, then streams the body via write() in chunks
  • Serves as the default implementation for adapters that don't need special handling (e.g., FPM)

Swoole adapter override using detach() + raw TCP

The Swoole Response adapter (Swoole/Response.php) overrides stream() to bypass Swoole's HTTP layer entirely:

  1. Builds the raw HTTP response — Constructs the status line, headers (including Content-Length), and Set-Cookie headers manually
  2. Calls $swooleResponse->detach() — Disconnects from Swoole's HTTP abstraction, giving us the raw file descriptor
  3. Uses $server->send($fd, $data) — Writes the HTTP headers and body chunks directly over TCP
  4. Closes the connection — Calls $server->close($fd) when done

This pattern is the officially supported way to send responses with both Content-Length and a streaming body in Swoole.

Server integration

The Swoole Server adapter (Swoole/Server.php) now injects the SwooleServer instance into each Response via setSwooleServer(), enabling the detach() + send() pattern.

Fix duplicate header appending in chunk()

A $chunking guard flag was added to the base Response::chunk() method to prevent headers and cookies from being appended multiple times when chunk() is called repeatedly during a chunked response.

Files Changed

File Change
src/Http/Response.php Added stream() method and $chunking guard in chunk()
src/Http/Adapter/Swoole/Response.php Added stream() override with detach()/send(), setSwooleServer(), and buildSetCookieHeader() helper
src/Http/Adapter/Swoole/Server.php Inject SwooleServer into Response via setSwooleServer()

Usage

$response->stream(
    fn(int $offset, int $length) => $storage->read($path, $offset, $length),
    $fileSize
);

The caller provides a reader callback and the total size. The framework handles headers, chunking, and transport-level details automatically.

Test Plan

  • Verify file downloads include Content-Length header in response
  • Verify browser shows download progress bar for large files
  • Verify small file downloads complete correctly
  • Verify cookies are properly set during file streaming
  • Verify fallback to base implementation works when SwooleServer is not injected
  • Verify chunk() no longer duplicates headers on repeated calls

🤖 Generated with Claude Code

Swoole's HTTP layer forces chunked Transfer-Encoding when using
$response->write() for streaming, which strips the Content-Length
header. This prevents browsers from showing download progress bars
and makes it impossible for clients to know the total file size
upfront.

This change introduces a stream() method that bypasses Swoole's
HTTP abstraction by using detach() + $server->send() to write raw
HTTP frames over TCP. This allows us to send both Content-Length
and a streaming body simultaneously.

Changes:
- Add stream(callable $reader, int $totalSize) to base Response
  class as a default implementation using write() + end()
- Override stream() in Swoole Response to use detach/send pattern
  for raw TCP streaming with Content-Length support
- Inject SwooleServer into Response via setSwooleServer() so the
  adapter can access send()/close() after detach()
- Fix duplicate header appending in chunk() with a $chunking guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-file-response

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant