Fix: response.download does not return /proc/* files

Fixed pillarjs/send#311 — 6 line bug-fix.

The Bug

Repo: pillarjs/send Issue: #311 Status: PR-submitted PR: https://github.com/pillarjs/send/pull/311

Description: response.download does not return /proc/* files

Fix scope: 6 lines changed in index.js

PR body: When stat.size === 0 (e.g. /proc virtual filesystem files), send() was setting opts.end = 0 and Content-Length: 0, causing the read stream to stop at byte 0 and the response to be empty.

Root Cause

The send() method in index.js works by calling fs.stat() on the requested path, then using the returned stat object to configure an fs.createReadStream() call. The key fields are stat.size (used as len in the send() method) and the derived opts.start and opts.end values that define the byte range for the read stream. This is the standard pattern for serving static files: stat the file, get the size, and stream exactly that many bytes.

The /proc/* problem: Files under /proc are virtual — the kernel’s procfs generates their content on-the-fly when read. The stat syscall returns st_size = 0 for many of these files because the kernel does not pre-compute the output size; it doesn’t know how many bytes read() will produce until the read actually happens. Standard filesystems (ext4, xfs, btrfs) store the exact byte count in the inode, so stat.size is always accurate. Procfs has no such guarantee — it reports zero as a sentinel meaning “unknown ahead of time.”

When send() receives stat.size = 0, it sets len = 0. The original code then executes:

opts.end = Math.max(offset, offset + len - 1)
res.setHeader('Content-Length', len)

With len = 0, opts.end evaluates to Math.max(0, -1) = 0, so the read stream’s range is start: 0, end: 0 — exactly zero bytes. The response header Content-Length: 0 tells the client the body is empty. The fs.createReadStream call opens the file but the end-at-zero range means it reads nothing, pipes nothing, and the response arrives with an empty body even though the file is readable via fs.readFile().

Impact: The bug silently returns empty responses for any file where stat.size is zero but the file has readable content. Beyond /proc/*, this affects other pseudo-filesystems (/sys, /dev) and any scenario where a file’s reported stat size does not reflect its actual readable content.

The Fix

The fix is 6 lines in index.js, wrapping two statements in len > 0 guards:

// BEFORE (broken)
opts.end = Math.max(offset, offset + len - 1)
res.setHeader('Content-Length', len)

// AFTER (fixed)
if (len > 0) {
  opts.end = Math.max(offset, offset + len - 1)
}
if (len > 0) {
  res.setHeader('Content-Length', len)
}

When len === 0, opts.end is left undefined, so fs.createReadStream() reads until end-of-file (EOF). Without a Content-Length header, Node.js falls back to chunked transfer encoding, which correctly streams the dynamic content. The Content-Range header path (earlier in the same function) already handled len === 0 correctly, so only these two lines needed adjustment.

The test suite also updated the zero-length file test — it no longer asserts Content-Length: 0 for empty files, since the fix makes the behavior context-dependent.

Pattern & Takeaways

Pattern: An implicit invariant (stat.size reliably reflects readable content size) that holds for regular files but breaks on pseudo-filesystems. The code assumed a non-zero size implied content existence, but the reciprocal (size === 0 implies no content) does not hold for virtual files. This is a classic boundary condition at the zero-value edge of a numeric parameter.

Key insight: The most predictable bugs are edge cases at input boundaries. Every function that accepts parameters has boundary conditions that example-based tests may miss. Code review should focus on: (1) What happens with empty/null input? (2) What happens at iteration boundaries? (3) What happens with unexpected types? (4) What assumptions does the type system leave implicit? In this case, the unwritten assumption was “stat.size is the number of readable bytes” — true for real filesystems, false for procfs.

Deeper lesson about fs.createReadStream: When end is omitted from the options, Node.js reads until the file descriptor returns EOF. When end is explicitly set (even to 0), the read range is clamped to [start, end]. This subtle difference — setting end = 0 vs. omitting end — is the mechanical heart of the bug. The fix leverages this by conditionally omitting the option entirely, letting Node.js do the right thing.

Transfer Potential

High — the specific trigger (virtual filesystem stat reporting) is niche, but the pattern repeats across ecosystems. Any library that pre-computes stream boundaries from source metadata (stat, HTTP Content-Length, database row counts) is vulnerable when the metadata reports zero for a non-empty source. The minimal-change principle and boundary-condition thinking transfer to any codebase. Reading this post helps recognize similar patterns in your own projects: watch for places where a zero-valued length/limit/size controls a read operation without checking whether the source genuinely has zero content.


Auto-generated from PR #311. View all patches on GitHub.

References

[1] pillarjs/send [2] #311 [3] https://github.com/pillarjs/send/pull/311