When the Total Is Unknown: Folder Download Progress
SILO Console v2.1.1 displayed NaN% in the Downloads / Uploads drawer while downloading a folder. Although the streamed ZIP archive continued transferring normally and stored objects were intact, the progress bar entered an invalid determinate state—causing operators to assume the transfer had stalled or failed and to click Download repeatedly.
The repair shipped in Console 2.2.0 (16960f7ab, tracking #62) and was embedded in Server 20260903 (edc8be6ed). It enforces a single rule:
A download may enter determinate mode only when it has a finite, positive total measured in bytes applicable to that response. Without such a total, it remains indeterminate until completion, failure, or cancellation.
(Console 2.4.1 later evolved folder and multi-selection downloads to use file-stream writers and native browser download handoff; see Subsequent Evolution below.)
Observed Failure
In SILO Console v2.1.1:
- Upload several objects under a common prefix such as
folder/. - In the parent bucket listing, select
folder/and click Download. - Open Downloads / Uploads while the transfer is in flight.
- The active row displays
NaN%while the ZIP stream continues downloading.
Root Cause
The visible NaN% resulted from a three-layer mismatch between S3 prefix metadata, streamed ZIP responses, and XHR progress arithmetic.
A Prefix Has No Object Size
S3 folders are logical common prefixes rather than stored directory objects. In the bucket listing model, a prefix ends with / and carries logical size = 0 (which Console renders as - in the table). Because the generated API schema marks size as omitempty, zero-valued sizes are omitted from listing JSON. The single-selection download thunk passed object.size directly into the download helper, supplying undefined (or 0 for synthetic prefix records) as fileSize. Neither is a valid denominator.
A Streamed ZIP Has No Known Wire Length
When the server receives a download request for a path ending in /, it lists objects recursively under that prefix and connects a zip.Writer directly to an io.Pipe. Objects are read, compressed with Deflate, and flushed to the HTTP response stream on the fly.
Streaming the ZIP avoids buffering the entire archive in server RAM or temporary disk space and delivers the first response byte immediately. However, because the final compressed archive size does not exist when HTTP response headers are written, the server sends Content-Type: application/zip without a Content-Length header. Summing uncompressed source object sizes cannot substitute for Content-Length because ProgressEvent.loaded measures compressed wire bytes plus ZIP framing headers—two different units.
Progress Events Without a Valid Denominator
The XHR progress listener unconditionally evaluated:
For a folder prefix or zero-byte object, fileSize was undefined or 0, producing NaN (loaded / undefined or 0 / 0) or Infinity (loaded / 0):
Product Contract
The progress UI requires four invariants:
Formulating the contract in terms of denominator validity rather than objectPath.endsWith("/") covers folder prefixes, zero-byte objects, omitted size metadata, and any future chunked response without special-casing object types.
Goals and Non-Goals
Goals
- Never render
NaN%,Infinity%, or a fabricated percentage during folder downloads. - Keep unknown-length transfers in the existing indeterminate progress animation.
- Preserve determinate percentage progress for ordinary non-empty files.
- Ensure completion, failure, and cancellation all exit indeterminate mode.
- Ensure zero-byte files complete cleanly at
100%without intermediateNaN%. - Prevent non-finite or out-of-range numbers from entering Redux state.
Non-Goals
- Buffering complete ZIP archives on the server just to compute
Content-Length. - Using the sum of uncompressed object sizes as a denominator for compressed ZIP wire bytes.
- Redesigning the Redux store schema in this bugfix.
Technical Decision
The repair comprises five focused rules:
D1. Calculate Only from a Valid Total
Extract progress arithmetic into a pure helper (downloadProgress.ts) with no DOM or Redux dependencies:
- A finite positive
objectSizepreserves existing ordinary-file progress calculation. - When
objectSizeis unavailable (0orundefined), a finite positiveevent.totalwithevent.lengthComputable === trueserves as fallback. - Otherwise, the function returns
null, signaling that no truthful percentage exists.
D2. Keep Unknown Totals Indeterminate
Update the XHR progress listener to dispatch progressCallback only when calculateDownloadPercent returns a non-null number:
Because download items initialize with waitingForFile = true—which ObjectHandled already renders as <ProgressBar variant="indeterminate" />—skipping progressCallback when percent === null keeps the row in indeterminate mode until completion, failure, or cancellation.
D3. Make Cancellation Terminal
Completion and failure already cleared waitingForFile, but cancelObjectInList did not. Setting item.waitingForFile = false on cancellation prevents an aborted indeterminate folder download from continuing to animate as if it were still active.
At the XHR boundary, calling req.abort() fires readystatechange (DONE, status = 0) before firing onabort. Guarding the generic DONE branch so status === 0 defers to onabort / onerror ensures user cancellation is recorded as Cancelled rather than Failed.
D4. Normalize Omitted Zero-Byte Sizes
Passing object.size || 0 in the single-selection download thunk restores the omitted logical zero before the response handler checks Blob.size === fileSize, allowing zero-byte objects to transition cleanly to 100% on HTTP 200 completion.
D5. Keep Server ZIP Streaming Unchanged
The Go folder-download handler continues streaming Deflate-compressed ZIP archives over io.Pipe without buffering on disk or in memory.
Progress State Machine
| State | waitingForFile |
percentage |
Terminal Flags | Rendered UI |
|---|---|---|---|---|
| Queued / awaiting first progress | true |
0 |
None | Indeterminate bar |
| Active unknown-total transfer | true |
0 |
None | Indeterminate bar |
| Active known-total transfer | false |
0..100 |
None | Determinate percentage |
| Completed | false |
100 |
done = true |
Completed (100%) |
| Failed | false |
Last valid | failed = true, done = true |
Error state |
| Cancelled | false |
0 |
cancelled = true, done = true |
Cancelled state |
Why This Design Is Sufficient
Ordinary Non-Empty File
objectSize > 0 provides a finite positive denominator, calculateDownloadPercent returns a clamped integer in [0, 100], updateProgress sets waitingForFile = false, and completion sets 100%.
Streamed Folder ZIP
objectSize is 0, lengthComputable is false, and event.total is 0. calculateDownloadPercent returns null, no progress action is dispatched, and the row stays indeterminate until the stream finishes and sets waitingForFile = false, percentage = 100, and done = true.
Future Response with Known Length
If a proxy or future archive endpoint supplies Content-Length (lengthComputable = true and event.total > 0), the fallback branch automatically renders determinate percentages without further code changes.
Zero-Byte File
The omitted size normalizes to 0. Any intermediate progress event returns null rather than evaluating 0 / 0, and upon receiving the empty 200 OK body, Blob.size === 0 matches fileSize === 0 and transitions directly to 100%.
Failure and Cancellation
Both failure and cancellation set waitingForFile = false and done = true, guaranteeing that no terminated transfer continues showing an active indeterminate animation.
Rejected Alternatives
Buffer the Entire ZIP on the Server
Generating the full ZIP in server memory or temporary disk storage just to populate Content-Length would delay time-to-first-byte, increase server memory/disk pressure, and degrade concurrent downloads.
Sum Uncompressed Object Sizes Under the Prefix
Summing logical source object sizes measures uncompressed input bytes, whereas event.loaded measures Deflate-compressed output bytes plus ZIP headers. Because the numerator and denominator have different units, the progress bar would stall below 100% or jump erratically with compression ratio.
Coerce Invalid Progress to 0%
Displaying a determinate 0% bar throughout a multi-gigabyte folder download misleads users into thinking zero bytes have transferred and the connection is dead.
Special-Case Paths Ending in /
Checking objectPath.endsWith("/") would miss zero-byte files, missing metadata fields, and any other chunked response lacking Content-Length. Checking denominator validity handles all of them uniformly.
Route Folders Through Immediate BrowserDownload
In Console 2.2.0, the legacy BrowserDownload helper clicked a hidden <a> tag and immediately marked the download 100% complete before the first byte arrived, sacrificing error detection and cancellation. (A proper native browser handoff was later designed in Console 2.4.1.)
Sanitize NaN Inside ProgressBar Only
Masking NaN only inside the React view component would leave corrupted NaN values in the Redux store and fail to keep waitingForFile in indeterminate mode.
Widen Redux State to percentage: number | null Immediately
Adding null alongside existing waitingForFile, done, failed, and cancelled booleans would multiply state combinations unnecessarily when waitingForFile = true already models and renders indeterminate progress.
Requirements and Acceptance
Functional Requirements
- FR1–FR3: Return only
nullor an integer in[0, 100]fromcalculateDownloadPercent, keep unknown-total folder transfers in indeterminate mode (waitingForFile = true), and preserve determinate percentages for known-total object downloads. - FR4–FR5: Normalize omitted zero-byte sizes so empty objects complete cleanly at
100%, and clearwaitingForFile = falseacross completion, failure, and cancellation.
Non-Functional Requirements
- Keep server-side ZIP streaming (
io.Pipewithout full-archive buffering), S3/Console API routes, and Redux state types unchanged.
Acceptance Criteria
- Zero
NaN%orInfinity%labels appear in the download drawer across folder, ordinary-file, zero-byte, and cancelled transfers.
Verification Plan
Pure Calculation Matrix
| Case | loaded |
objectSize |
lengthComputable |
event.total |
Expected |
|---|---|---|---|---|---|
| Ordinary file, halfway | 50 |
100 |
false |
0 |
50 |
Common prefix (folder/) |
1024 |
0 |
false |
0 |
null |
Initial 0 / 0 |
0 |
0 |
false |
0 |
null |
| Response-total fallback | 50 |
0 |
true |
200 |
25 |
| Zero response total | 0 |
0 |
true |
0 |
null |
| Loaded exceeds total | 150 |
100 |
true |
100 |
100 |
Invalid NaN object size |
10 |
NaN |
false |
0 |
null |
Omitted undefined size |
10 |
undefined |
false |
0 |
null |
Non-finite Infinity total |
10 |
0 |
true |
Infinity |
null |
Negative loaded |
-1 |
100 |
true |
100 |
null |
Reducer State Transitions
Unit tests verify that new downloads start with waitingForFile = true, remain indeterminate when no numeric progress action is dispatched, transition to determinate on valid progress, and clear waitingForFile = false across completion, failure, and cancellation.
End-to-End Browser Regression
Playwright tests against a live SILO + Console instance use Chromium CDP network throttling to observe an active folder download, verify that the drawer shows an indeterminate bar with zero NaN% or Infinity% text, test mid-stream cancellation, and verify full ZIP completion alongside ordinary and zero-byte files.
Implementation Scope
- Add
downloadProgress.tswithcalculateDownloadPercent. - Update
Objects/utils.tsto dispatchprogressCallbackonly for non-null percentages and letstatus === 0XHR completions reachonabort/onerror. - Normalize
object.size || 0in the single-selection download thunk. - Set
item.waitingForFile = falseincancelObjectInList. - Add unit, reducer, and throttled Chromium Playwright regression tests.
Delivery and Rollback
Shipped in Console v2.2.0 (silo-console#29) and embedded in SILO Server 20260903, resolving pgsty/silo#62. No storage or API migration is required.
Definition of Done
-
calculateDownloadPercentreturns onlynullor a finite integer in[0, 100]. - Active unknown-total folder downloads render indeterminate progress; ordinary and zero-byte files render valid determinate completion.
- Completed, failed, and cancelled rows exit indeterminate mode (
waitingForFile = false). - Shipped in Console
2.2.0and Server20260903.
Follow-Up Work
Five follow-up items identified during this review—streaming downloads directly to the browser without in-memory Blob buffering, discriminated progress state, mid-stream ZIP error signaling, component-level non-finite guards, and Blob JSON error decoding—were tracked separately and addressed in subsequent Console releases.
Subsequent Evolution: Browser Streaming Handoff in Console 2.4.1
Alongside this progress repair, Console 2.2.0 also made size an always-present JSON field in object listings and hardened server-side ZIP error propagation so unreadable entries abort the stream rather than being silently omitted.
Subsequently, Console 2.4.1 eliminated in-memory XHR Blob buffering for downloads by handing single-folder and single-object downloads directly to the browser’s native download manager and streaming multi-selection archives through file-system writers where supported. Under that newer architecture, the Console transfer row records the handoff to the browser, while ongoing byte progress and cancellation are managed natively by the browser’s download manager.