Skip to content

Serve HTTP Range requests from the static-file path#46

Merged
hellerve merged 1 commit into
mainfrom
claude/range-requests
Jul 23, 2026
Merged

Serve HTTP Range requests from the static-file path#46
hellerve merged 1 commit into
mainfrom
claude/range-requests

Conversation

@carpentry-agent

Copy link
Copy Markdown
Contributor

What

Adds single-range HTTP Range support to the static-file (sendfile) serving path. The server now answers 206 Partial Content for Range: bytes=… requests, turning the per-fd transfer machinery (sf-offsets/sf-sizes) — which already existed for partial transfers — into a real feature.

Supported forms (single range only):

  • bytes=A-B — inclusive [A, B], B clamped to EOF
  • bytes=A- — from A to EOF
  • bytes=-N — last N bytes

Responses:

  • satisfiable206 with Content-Range: bytes A-B/total, Accept-Ranges: bytes, and Content-Length = range length; the fd's sendfile offset is seeded to A and its size cap to B+1, so the existing transfer loop sends exactly [A, B].
  • unsatisfiable (start ≥ size) → 416 Range Not Satisfiable with Content-Range: bytes */total, no transfer.
  • absent / malformed / multiple comma-separated ranges → the normal full 200 (a server MAY ignore Range; keeps scope tight and is spec-compliant).

Every sendfile 200 now advertises Accept-Ranges: bytes. HEAD requests get the same status line and headers with an empty body.

How

The response is built (web-build-response) before the file size is known, but the 206/Content-Range decision needs it. The size is only learned when the server opens the file — at GET transfer setup, and (for HEAD) in web-strip-head-body. So the raw Range value is carried from the parsed request to those points as an internal X-Sendfile-Range marker header (mirroring the existing X-Sendfile convention) and stripped before serialization. Resolution against the real size happens once, where the file is opened.

web-resolve-range is the pure core: (spec, total) -> Full | Partial [start end] | Unsatisfiable. Bound parsing saturates at total+1 once a value exceeds the file size, so oversized/malicious specs (e.g. bytes=999999999999-) can't overflow Long (which is 32-bit on some targets) — resolved bounds always stay within [0, total-1], so there is no bad-offset path into sendfile.

The GET setup that opened the file and registered transfer state inline is extracted into App.setup-sendfile, which now also resolves the range.

Testing

  • test/web.carp: 236/0 (was 206). Adds pure web-resolve-range cases (bytes=0-499, 500-, -500, 0-, clamp past EOF, start≥size, bytes=abc/bytes=/missing-bytes=, multiple ranges, -0, empty file, overflow) plus HEAD integration tests through web-build-response against the 11-byte static-fixtures/index.html fixture (206 + Content-Range + range-length Content-Length + empty body; 416; Accept-Ranges on the plain 200).
  • test/websocket.carp: 128/0 (unchanged).
  • Non-vacuity: mutating the end-clamp to drop the min(b, total-1) flips bytes=0-100000 clamps end to EOF to failing (235/1); reverting restores 236/0.
  • test/smoke.sh (runs in CI): drives a real server with curl -r for a 206 body, an open-ended range that reassembles to the whole file, and a 416. Verified locally end-to-end (including a mid-file bytes=5-9index, confirming the offset seek).
  • carp-fmt --check and angler clean on changed files (no new findings).

carp -b doesn't emit a binary on the armhf dev box, so the smoke script itself was validated by running the same server via carp -x and exercising the endpoints with curl.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

Answer a single byte range (bytes=A-B, bytes=A-, bytes=-N) with 206
Partial Content and a Content-Range header, driving the transfer by
seeding the fd's sendfile offset/size so the existing loop sends exactly
the requested window. Unsatisfiable ranges get 416; absent, malformed,
and multi-range specs fall back to the full 200. Every sendfile response
now advertises Accept-Ranges: bytes, and HEAD gets the same status and
headers with no body.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Build & Tests

Checked out claude/range-requests (37db046, based on current main) and ran it on armhf via carp -x (carp -b emits no binary here):

  • test/web.carp236/0
  • test/websocket.carp128/0 (unchanged — refactor preserved)
  • CHANGELOG entry lands correctly under ## Unreleased### Added (branch is on current main, so no mis-filing).
  • CI: test (macos-latest) green — the real signal, since web's matrix is macOS-only.

Findings

No blocking findings. I went past the suite to try to break it:

  • Non-vacuity, independently. Beyond your end-clamp mutation, I mutated the both-bounds boundary (>= a total)(> a total) and the suite caught exactly start equal to size is unsatisfiable (235/1). Harness is real.
  • Overflow-safe parse holds. web-parse-bound only computes acc*10 on the branch where acc ≤ total/10 (the or short-circuits before the second disjunct), so oversized specs saturate at total+1 with no 32-bit Long overflow. Probed bytes=999999999999-UNSAT, bytes=-999999999999 → whole file. Good.
  • End-to-end GET seek verified. Ran the smoke server (carp -x) + curl: bytes=5-9 → 206 Content-Range: bytes 5-9/11, body index — the mid-file sendfile offset seed works, not just the pure/HEAD path. bytes=-3bytes 8-10/11; bytes=100-200 → 416 bytes */11 CL 0; plain GET → 200 + Accept-Ranges; [0-4]+[5-10] reassembles to the full file; server shut down clean (no orphan fd).
  • Range header is case-insensitive. range:/RANGE:/Range: all yield 206 (via http@0.2.0's header-lookup) — confirmed at the wire level with -H 'range: ...'.
  • No internal-header leak. The X-Sendfile-Range marker is stripped in every GET/HEAD branch, and web-not-modified rebuilds a clean 304 (only ETag), so a Range + matching If-None-Match can't leak the marker.
  • Malformed / multi-range / leading-space specs all fall back to 200 (spec-allows-MAY-ignore); bytes=-0 and any range on an empty file → unsatisfiable. All correct.

Nice reuse of the existing X-Sendfile marker convention to carry the raw spec to file-open time — no new transfer-loop code, async hot path untouched.

Verdict: merge

Correct, overflow-safe, and verified end-to-end including the mid-file seek; CI green. The draft state is intentional (yours to un-draft) — leaving it as-is.

@hellerve
hellerve marked this pull request as ready for review July 23, 2026 16:04
@hellerve
hellerve merged commit 893e506 into main Jul 23, 2026
1 check passed
@hellerve
hellerve deleted the claude/range-requests branch July 23, 2026 16:05
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