Skip to content

feat: serve HTTP byte ranges on the peer file endpoint (#13 1/5)#16

Merged
roziscoding merged 4 commits into
mainfrom
feat/harden-peer-downloads/1-range-serving
Jun 6, 2026
Merged

feat: serve HTTP byte ranges on the peer file endpoint (#13 1/5)#16
roziscoding merged 4 commits into
mainfrom
feat/harden-peer-downloads/1-range-serving

Conversation

@roziscoding
Copy link
Copy Markdown
Owner

@roziscoding roziscoding commented Jun 6, 2026

Stack 1/5 for #13 (harden peer download handling). Base: main.

Closes #13 (the full feature lands across this 5-PR stack; #16 is the bottom).

What this PR does

Teaches the peer file endpoint (GET /peer/items/:itemId/file) to serve byte ranges, so a downloading peer can later resume from an offset instead of restarting. This is the serving-side foundation; the client side that consumes it lands in PR 2.

  • PeerController.streamFile(id, rangeHeader?) now parses/resolves a single-range HTTP Range header and returns a discriminated result: full | partial | unsatisfiable.
  • parseRangeHeader() handles normal (bytes=2-4), open-ended (bytes=5-), and suffix (bytes=-3) ranges; absent/malformed/multi-range headers fall back to a full 200.
  • The router maps the result to: 206 Partial Content + Content-Range + Content-Length + Accept-Ranges: bytes; 416 Range Not Satisfiable (Content-Range: bytes */total) for unsatisfiable ranges; and a full 200 + Accept-Ranges: bytes otherwise.
  • Partial bodies stream lazily via Bun.file().slice(start, end + 1).stream() (no buffering of the slice into memory). slice is half-open, hence end + 1.

Full stack

  1. feat: serve HTTP byte ranges on the peer file endpoint (#13 1/5) #16 — Range serving (this PR)
  2. feat: resume interrupted peer downloads via HTTP Range (#13 2/5) #17 — Client resume via HTTP Range
  3. feat: retry, semaphore, and download concurrency/retry config (#13 3/5) #18 — retry() + Semaphore + config
  4. feat: track download attempts and expose stale rows for re-drive (#13 4/5) #19 — attempts column + repo re-drive methods
  5. feat: bound, retry, and resume peer downloads end-to-end (#13 5/5) #20 — DownloadsService wiring + startup re-enqueue

Files

  • apps/backend/src/modules/peer/peer.controller.ts
  • apps/backend/src/modules/peer/peer.router.ts
  • apps/backend/src/__tests__/peer-range-serving.test.ts (new)

Testing

12 new tests covering range parsing, controller resolution (normal/suffix/open-ended/unsatisfiable/full/unknown-source), and router HTTP responses (200/206/416/404). Full suite green.

Review focus

  • Range math bounds: Number.isSafeInteger validation, suffix clamping (Math.max(total - n, 0)), start >= totalSize → 416, half-open slice off-by-one.
  • Auth is unchanged: the route stays behind the existing global requireApiKey middleware.

Greptile Summary

This PR adds HTTP byte-range serving to the GET /peer/items/:itemId/file endpoint, laying the foundation for resumable peer downloads in the broader stack.

  • parseRangeHeader() correctly handles normal (bytes=2-4), open-ended (bytes=5-), and suffix (bytes=-3) forms, returning null for absent/malformed/multi-range inputs so they fall back to a full 200.
  • streamFile() resolves the parsed range against the actual file size and returns a discriminated full | partial | unsatisfiable union; all boundary conditions (empty file, inverted range, start-beyond-EOF, suffix clamp) produce the correct result.
  • The router maps the union to 200 + Accept-Ranges, 206 + Content-Range + Content-Length, or 416 + Content-Range: bytes */total; the half-open slice(start, end + 1) is correctly used to include the last byte.

Confidence Score: 5/5

Safe to merge — range math is correct across all boundary conditions and the discriminated result type ensures exhaustive handling in the router.

The range arithmetic (suffix clamp, open-ended clamp, inverted-range rejection, start-beyond-EOF rejection) has been verified against all edge cases, the slice(start, end+1) half-open interval is correctly applied, and the 12-test suite exercises every code path including HTTP status codes and response headers.

No files require special attention.

Important Files Changed

Filename Overview
apps/backend/src/modules/peer/peer.controller.ts Adds parseRangeHeader() and expands streamFile() to return a discriminated full
apps/backend/src/modules/peer/peer.router.ts Maps StreamFileResult to 200/206/416 with correct Content-Range, Content-Length, and Accept-Ranges headers; fallthrough to full is type-safe.
apps/backend/src/tests/peer-range-serving.test.ts New test file with 12 tests covering parser edge cases, controller slice math, and HTTP response codes/headers; uses Bun.write for fixture setup per project style.

Sequence Diagram

sequenceDiagram
    participant Client as Downloading Peer
    participant Router as peer.router
    participant Controller as PeerController
    participant FS as Bun.file()

    Client->>Router: "GET /peer/items/:id/file [Range: bytes=X-Y]"
    Router->>Controller: streamFile(id, rangeHeader)
    Controller->>Controller: parseRangeHeader(rangeHeader)
    alt No / malformed Range header
        Controller-->>Controller: "range = null → full"
    else "Normal range bytes=S-E"
        Controller-->>Controller: "start=S, end=min(E, total-1)"
    else "Open-ended bytes=S-"
        Controller-->>Controller: "start=S, end=total-1"
    else "Suffix bytes=-N"
        Controller-->>Controller: "start=max(total-N,0), end=total-1"
    end
    Controller->>FS: Bun.file(filePath)
    FS-->>Controller: "BunFile (size=total)"
    alt full
        Controller-->>Router: "{type:'full', stream, size, filename}"
        Router-->>Client: 200 OK + Accept-Ranges: bytes
    else "partial (start < total, start <= end)"
        Controller->>FS: file.slice(start, end+1).stream()
        FS-->>Controller: ReadableStream
        Controller-->>Router: "{type:'partial', stream, start, end, size, totalSize}"
        Router-->>Client: 206 Partial Content + Content-Range: bytes S-E/total
    else "unsatisfiable (start >= total or start > end)"
        Controller-->>Router: "{type:'unsatisfiable', totalSize}"
        Router-->>Client: "416 Range Not Satisfiable + Content-Range: bytes */total"
    end
Loading

Reviews (4): Last reviewed commit: "chore: sync bun.lock with hono bump from..." | Re-trigger Greptile

@roziscoding roziscoding marked this pull request as ready for review June 6, 2026 01:22
Comment thread apps/backend/src/__tests__/peer-range-serving.test.ts Outdated
Comment thread apps/backend/src/modules/peer/peer.controller.ts
roziscoding and others added 4 commits June 6, 2026 16:13
Parse the Range header in the peer file route and serve 206 Partial
Content with Content-Range, 416 Range Not Satisfiable for unsatisfiable
ranges, and Accept-Ranges: bytes on full responses. streamFile now
returns a discriminated result (full/partial/unsatisfiable) resolved
against the file size, streaming only the requested slice.

Foundation for resumable peer downloads (#13).
* feat: resume interrupted peer downloads via HTTP Range

downloadFile now detects an existing .part file and sends
Range: bytes=<size>-, validating the peer's 206 + Content-Range against
the persisted expected size before appending. On 200 (range ignored), a
Content-Range mismatch, or 416 it discards the stale .part and restarts
from byte 0, emitting a restart progress event. The write path uses a
node:fs FileHandle (append/write) with datasync at checkpoints, and the
.part is preserved on error so the next attempt can resume. A truncated
stream throws a retryable IncompleteDownloadError.

Refs #13.

* feat: retry, semaphore, and download concurrency/retry config (#13 3/5) (#18)

* feat: add retry, semaphore, and download concurrency/retry config

Add a generic retry() helper (bounded attempts, exponential backoff with
full jitter, optional Retry-After override, injectable sleep/random) and
a download retry classifier (transient: network/timeout/5xx/429/incomplete
stream; permanent: non-429 4xx and others). Add a FIFO async Semaphore.
Extend DownloadsConfig with maxConcurrentDownloads and retry knobs (all
defaulted so existing configs keep parsing). Primitives are wired into
DownloadsService in a later change.

Refs #13.

* feat: track download attempts and expose stale rows for re-drive (#13 4/5) (#19)

* feat: track download attempts and expose stale rows for re-drive

Add an attempts column to the downloads table (additive migration) and
repository methods: incrementAttempts, markResumeReset (reset
downloadedBytes and record the resume-from-zero transition), and
listStaleDownloads (returns stale downloading rows without mutating
them, for active startup re-enqueue). reconcileStaleDownloads is kept
as the fallback for when downloads is unconfigured.

Refs #13.

* feat: bound, retry, and resume peer downloads end-to-end (#13 5/5) (#20)

* feat: bound, retry, and resume peer downloads end-to-end

Rewire DownloadsService around a shared Semaphore (maxConcurrentDownloads),
a retry loop (bounded backoff+jitter, attempts tracked, transient vs
permanent classification, Retry-After honored), and resume: the restart
progress event persists via markResumeReset, and an active/reenqueued
dedupe prevents duplicate rows. On startup, index.ts re-drives stale
downloading rows with resumeStaleDownloads() before the watcher scans,
falling back to reconcileStaleDownloads() when downloads is unconfigured.

Closes #13.

* fix: harden startup re-enqueue dedupe (review feedback)

- Dedupe stale downloading rows by destPath before re-driving: only one
  row per destination is resumable (they share the same .part), so mark
  the superseded duplicates failed instead of letting the second silently
  early-return in runDownload and stay stuck in downloading.
- Release the reenqueued claim on successful resume (stub already
  unlinked, so no scan race) so a later legitimate re-drop of the same
  torrent filename is not silently skipped for the rest of the process.

Refs #13.

* fix: address retry review feedback

* fix: guard non-ok resume responses

* fix: avoid leaked peer download reader lock

* fix: close peer download handle on reader failure
@roziscoding roziscoding force-pushed the feat/harden-peer-downloads/1-range-serving branch from f67a110 to 1a36259 Compare June 6, 2026 14:14
@roziscoding roziscoding merged commit 8868923 into main Jun 6, 2026
6 checks passed
@roziscoding roziscoding deleted the feat/harden-peer-downloads/1-range-serving branch June 6, 2026 14:17
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.

feat: harden peer download handling

1 participant