fix: large file downloads stuck at 0 bytes in browsers#11382
fix: large file downloads stuck at 0 bytes in browsers#11382lohanidamodar wants to merge 5 commits into1.8.xfrom
Conversation
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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe 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 Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Security Scan Results for PRDocker Image Scan Results
Source Code Scan Results🎉 No vulnerabilities found! |
There was a problem hiding this comment.
🧹 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 >= $endto$start > $endcorrectly admits thebytes=0-0single-byte range case. Note that this file still uses a loose!=for$unit != 'bytes', whileDownload/Get.php(same PR) was updated to strict!==. The loose comparison works identically here sincegetRangeUnit()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 assertingAccept-RangesandContent-Lengthon 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 a206 Partial Contentresponse MUST include aContent-Rangeheader, and the PR's stated goal includes advertisingAccept-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
📒 Files selected for processing (3)
src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.phpsrc/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.phptests/e2e/Services/Storage/StorageBase.php
✨ Benchmark results
⚡ Benchmark Comparison
|
…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>
…e into fix-large-file-download
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()) callsappendHeaders()on every iteration of the chunk loop. After the firstwrite()call, Swoole has already sent the HTTP headers. In Swoole 6, calling$response->status()or$response->header()afterwrite()throws an error. This\Throwableis caught by the HTTP request handler inapp/http.php, which calls$swooleResponse->end()— terminating the response and producing 0-byte downloads.Range requests work fine because they use
$response->send(), which callsappendHeaders()only once.Fix
1. Override chunk() in Appwrite\Utopia\Response
Add a
$chunkingflag that ensuresappendCookies()/appendHeaders()are called only on the first invocation — matching howsend()already works. This prevents Swoole errors on subsequent chunk iterations.2. Fix range validation off-by-one
$start >= $endto$start > $endacross all download endpoints. The old condition incorrectly rejected valid single-byte probe requests likeRange: 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-Lengthfrom 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 guardStorage/Http/Buckets/Files/Download/Get.php— range validation, header fixesStorage/Http/Buckets/Files/View/Get.php— same fixesStorage/Http/Buckets/Files/Push/Get.php— same fixesFunctions/Http/Deployments/Download/Get.php— same fixesSites/Http/Deployments/Download/Get.php— same fixestests/e2e/Services/Storage/StorageBase.php— test assertionsTest plan