Skip to content

fix: large file downloads stuck at 0 bytes in browsers#11382

Draft
lohanidamodar wants to merge 5 commits into1.8.xfrom
fix-large-file-download
Draft

fix: large file downloads stuck at 0 bytes in browsers#11382
lohanidamodar wants to merge 5 commits into1.8.xfrom
fix-large-file-download

Conversation

@lohanidamodar
Copy link
Member

@lohanidamodar lohanidamodar commented Feb 23, 2026

Problem

When downloading files larger than 20 MB through a browser or wget, the download shows 0 bytes and never progresses. Download managers work because they use Range requests, which take the send() code path.

Root Cause

The framework's chunk() method (Utopia\Http\Response::chunk()) calls appendHeaders() on every iteration of the chunk loop. After the first write() call, Swoole has already sent the HTTP headers. In Swoole 6, calling $response->status() or $response->header() after write() throws an error. This \Throwable is caught by the HTTP request handler in app/http.php, which calls $swooleResponse->end() — terminating the response and producing 0-byte downloads.

Range requests work fine because they use $response->send(), which calls appendHeaders() only once.

Fix

1. Override chunk() in Appwrite\Utopia\Response

Add a $chunking flag that ensures appendCookies()/appendHeaders() are called only on the first invocation — matching how send() already works. This prevents Swoole errors on subsequent chunk iterations.

2. Fix range validation off-by-one

$start >= $end to $start > $end across all download endpoints. The old condition incorrectly rejected valid single-byte probe requests like Range: bytes=0-0.

3. Always advertise Accept-Ranges: bytes

Moved out of the range-only block so browsers learn range requests are supported on plain GET responses.

4. Correct Content-Length placement

Removed Content-Length from common headers (Swoole ignores it for write-based chunked responses) and kept it only in the range response block where it is effective.

Files Changed

  • src/Appwrite/Utopia/Response.php — chunk() override with $chunking guard
  • Storage/Http/Buckets/Files/Download/Get.php — range validation, header fixes
  • Storage/Http/Buckets/Files/View/Get.php — same fixes
  • Storage/Http/Buckets/Files/Push/Get.php — same fixes
  • Functions/Http/Deployments/Download/Get.php — same fixes
  • Sites/Http/Deployments/Download/Get.php — same fixes
  • tests/e2e/Services/Storage/StorageBase.php — test assertions

Test plan

  • Existing storage E2E tests pass
  • Range: bytes=0-0 probe returns 206, not 416
  • Plain GET response includes Accept-Ranges: bytes
  • Downloading a file >20 MB in browser/wget shows progress and completes

Three bugs in the storage file download and view endpoints caused browsers
to stall at 0 bytes when downloading files larger than 20 MB:

1. Missing Content-Length on full-file (non-range) responses — browsers
   had no way to know the file size, causing progress to show 0 bytes and
   some browsers to buffer or stall the chunked transfer internally.

2. Missing Accept-Ranges: bytes on plain GET responses — browsers never
   learned range requests were supported, so they could not retry with a
   Range header. Download managers worked because they always probe with
   Range requests, which did hit the correct code path.

3. Off-by-one in range boundary validation ($start >= $end) incorrectly
   rejected valid single-byte probe requests like Range: bytes=0-0 with
   a 416 error. Changed to $start > $end.

Both Content-Length and Accept-Ranges are now sent unconditionally on
every response. The Content-Length is set to the full file size for plain
GET requests and to the chunk size for partial-content (206) responses.

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

coderabbitai bot commented Feb 23, 2026

Important

Review skipped

Draft detected.

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
📝 Walkthrough

Walkthrough

The changes modify HTTP range request handling in file download and view endpoints. The key modification allows single-byte ranges (where start equals end) by changing the validation condition from start >= end to start > end. Additionally, the header structure is refactored: Accept-Ranges and Content-Length headers are now added to the final response instead of only in the partial-content branch. This ensures consistent header presence across both ranged and non-ranged responses. Tests are updated to verify the new behavior including single-byte range requests.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: large file downloads stuck at 0 bytes in browsers' accurately summarizes the main problem being addressed in the changeset.
Description check ✅ Passed The PR description clearly explains the problem (0-byte downloads for files >20MB), root causes, and detailed fixes with specific file changes and test validation steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

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

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.

@github-actions
Copy link

github-actions bot commented Feb 23, 2026

Security Scan Results for PR

Docker Image Scan Results

Package Version Vulnerability Severity
libecpg 18.1-r0 CVE-2026-2004 HIGH
libecpg 18.1-r0 CVE-2026-2005 HIGH
libecpg 18.1-r0 CVE-2026-2006 HIGH
libecpg 18.1-r0 CVE-2026-2007 HIGH
libecpg-dev 18.1-r0 CVE-2026-2004 HIGH
libecpg-dev 18.1-r0 CVE-2026-2005 HIGH
libecpg-dev 18.1-r0 CVE-2026-2006 HIGH
libecpg-dev 18.1-r0 CVE-2026-2007 HIGH
libheif 1.20.2-r1 CVE-2025-68431 HIGH
libpng 1.6.54-r0 CVE-2026-25646 HIGH
libpng-dev 1.6.54-r0 CVE-2026-25646 HIGH
libpq 18.1-r0 CVE-2026-2004 HIGH
libpq 18.1-r0 CVE-2026-2005 HIGH
libpq 18.1-r0 CVE-2026-2006 HIGH
libpq 18.1-r0 CVE-2026-2007 HIGH
libpq-dev 18.1-r0 CVE-2026-2004 HIGH
libpq-dev 18.1-r0 CVE-2026-2005 HIGH
libpq-dev 18.1-r0 CVE-2026-2006 HIGH
libpq-dev 18.1-r0 CVE-2026-2007 HIGH
postgresql18-dev 18.1-r0 CVE-2026-2004 HIGH
postgresql18-dev 18.1-r0 CVE-2026-2005 HIGH
postgresql18-dev 18.1-r0 CVE-2026-2006 HIGH
postgresql18-dev 18.1-r0 CVE-2026-2007 HIGH

Source Code Scan Results

🎉 No vulnerabilities found!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php (1)

147-149: Range validation fix is correct; minor comparison inconsistency with Download endpoint.

Changing $start >= $end to $start > $end correctly admits the bytes=0-0 single-byte range case. Note that this file still uses a loose != for $unit != 'bytes', while Download/Get.php (same PR) was updated to strict !==. The loose comparison works identically here since getRangeUnit() returns a string, but consistency between the two files is preferable.

🔧 Suggested fix
-            if ($unit != 'bytes' || $start > $end || $end >= $size) {
+            if ($unit !== 'bytes' || $start > $end || $end >= $size) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php` around
lines 147 - 149, Update the range validation to use a strict comparison for the
range unit like the Download endpoint: in the same conditional that now checks
($unit != 'bytes' || $start > $end || $end >= $size) replace the loose != with
!== so it reads ($unit !== 'bytes' || $start > $end || $end >= $size); this
keeps behavior identical (getRangeUnit() returns a string) but ensures
consistency with the other updated file and the Exception::STORAGE_INVALID_RANGE
check remains unchanged.
tests/e2e/Services/Storage/StorageBase.php (2)

696-706: Consider asserting Accept-Ranges and Content-Length on the large-file download.

The large-file download is the primary regression scenario called out in the PR description, yet the test does not assert the two headers that the fix introduces for this path. Adding these assertions here would directly anchor the fix to the test.

🧪 Suggested additions
 $this->assertEquals(200, $file7['headers']['status-code']);
 $this->assertEquals('attachment; filename="large-file.mp4"', $file7['headers']['content-disposition']);
 $this->assertEquals('video/mp4', $file7['headers']['content-type']);
+$this->assertEquals('bytes', $file7['headers']['accept-ranges']);
+$this->assertNotEmpty($file7['headers']['content-length']);
 $this->assertNotEmpty($fileData);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Services/Storage/StorageBase.php` around lines 696 - 706, The test
for downloading the large file should also assert the new headers introduced by
the fix: after obtaining $file7 and $fileData in StorageBase.php (the GET to
'/storage/buckets/.../files/.../download' that returns large-file.mp4), add
assertions that $file7['headers']['accept-ranges'] equals 'bytes' (or contains
'bytes') and that $file7['headers']['content-length'] equals the filesize of the
source file (use filesize(realpath(__DIR__ .
'/../../../resources/disk-a/large-file.mp4')) or cast to string/int to match
header type) so the test verifies both Accept-Ranges and Content-Length for the
large-file download path.

622-630: Extend single-byte probe assertions to cover body and required headers.

The test validates the status and Content-Length, but per RFC 7233 §4.1 a 206 Partial Content response MUST include a Content-Range header, and the PR's stated goal includes advertising Accept-Ranges. Covering those here would complete the contract and guard against future regressions.

🧪 Suggested additions
 $this->assertEquals(206, $file51a['headers']['status-code']);
 $this->assertEquals('1', $file51a['headers']['content-length']);
+$this->assertEquals('bytes 0-0/47218', $file51a['headers']['content-range']);
+$this->assertEquals('bytes', $file51a['headers']['accept-ranges']);
+$path = __DIR__ . '/../../../resources/logo.png';
+$this->assertEquals(substr(file_get_contents($path), 0, 1), $file51a['body']);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Services/Storage/StorageBase.php` around lines 622 - 630, The
single-byte probe test ($file51a) only checks status and content-length; add
assertions to verify the response includes the required headers and body: assert
the presence and value of the Content-Range header (e.g. startsWith or equals
"bytes 0-0/{size}" using $data['size'] if available), assert that Accept-Ranges
is "bytes", and assert the response body is exactly one byte
(strlen($file51a['body']) === 1) and matches the first byte of the source
content (e.g. substr($data['content'], 0, 1) or equivalent); use the existing
$file51a variable and the client->call result to locate where to add these
checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php`:
- Around line 147-149: Update the range validation to use a strict comparison
for the range unit like the Download endpoint: in the same conditional that now
checks ($unit != 'bytes' || $start > $end || $end >= $size) replace the loose !=
with !== so it reads ($unit !== 'bytes' || $start > $end || $end >= $size); this
keeps behavior identical (getRangeUnit() returns a string) but ensures
consistency with the other updated file and the Exception::STORAGE_INVALID_RANGE
check remains unchanged.

In `@tests/e2e/Services/Storage/StorageBase.php`:
- Around line 696-706: The test for downloading the large file should also
assert the new headers introduced by the fix: after obtaining $file7 and
$fileData in StorageBase.php (the GET to
'/storage/buckets/.../files/.../download' that returns large-file.mp4), add
assertions that $file7['headers']['accept-ranges'] equals 'bytes' (or contains
'bytes') and that $file7['headers']['content-length'] equals the filesize of the
source file (use filesize(realpath(__DIR__ .
'/../../../resources/disk-a/large-file.mp4')) or cast to string/int to match
header type) so the test verifies both Accept-Ranges and Content-Length for the
large-file download path.
- Around line 622-630: The single-byte probe test ($file51a) only checks status
and content-length; add assertions to verify the response includes the required
headers and body: assert the presence and value of the Content-Range header
(e.g. startsWith or equals "bytes 0-0/{size}" using $data['size'] if available),
assert that Accept-Ranges is "bytes", and assert the response body is exactly
one byte (strlen($file51a['body']) === 1) and matches the first byte of the
source content (e.g. substr($data['content'], 0, 1) or equivalent); use the
existing $file51a variable and the client->call result to locate where to add
these checks.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e2d6b76 and 58f33b7.

📒 Files selected for processing (3)
  • src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php
  • src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php
  • tests/e2e/Services/Storage/StorageBase.php

@github-actions
Copy link

github-actions bot commented Feb 23, 2026

✨ Benchmark results

  • Requests per second: 0
  • Requests with 200 status code: n,ull
  • P99 latency: null

⚡ Benchmark Comparison

Metric This PR Latest version
RPS 0 1,189
200 n,ull 214,098
P99 null 0.187828964

lohanidamodar and others added 2 commits February 24, 2026 07:12
…ads)

The previous commit added Content-Length headers, but Swoole ignores
Content-Length when using write() (chunked transfer encoding). The real
root cause is that the framework's chunk() method calls appendHeaders()
on every iteration, which calls sendStatus() and sendHeader() after
write() has already been called. In Swoole 6, this throws an error that
gets caught by the HTTP request handler, which then calls end() —
terminating the response after the first chunk and producing 0-byte
downloads.

Override chunk() in Appwrite\Utopia\Response with a $chunking flag that
ensures appendCookies()/appendHeaders() are called only on the first
invocation — matching how send() already works. Also move Content-Length
out of common headers (Swoole ignores it for write-based responses) and
into the range-only block where it's effective.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lohanidamodar lohanidamodar marked this pull request as draft February 24, 2026 06:30
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