feat: serve HTTP byte ranges on the peer file endpoint (#13 1/5)#16
Merged
Merged
Conversation
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
f67a110 to
1a36259
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 HTTPRangeheader 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 full200.206 Partial Content+Content-Range+Content-Length+Accept-Ranges: bytes;416 Range Not Satisfiable(Content-Range: bytes */total) for unsatisfiable ranges; and a full200+Accept-Ranges: bytesotherwise.Bun.file().slice(start, end + 1).stream()(no buffering of the slice into memory).sliceis half-open, henceend + 1.Full stack
Files
apps/backend/src/modules/peer/peer.controller.tsapps/backend/src/modules/peer/peer.router.tsapps/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
Number.isSafeIntegervalidation, suffix clamping (Math.max(total - n, 0)),start >= totalSize→ 416, half-opensliceoff-by-one.requireApiKeymiddleware.Greptile Summary
This PR adds HTTP byte-range serving to the
GET /peer/items/:itemId/fileendpoint, 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, returningnullfor absent/malformed/multi-range inputs so they fall back to a full200.streamFile()resolves the parsed range against the actual file size and returns a discriminatedfull | partial | unsatisfiableunion; all boundary conditions (empty file, inverted range, start-beyond-EOF, suffix clamp) produce the correct result.200 + Accept-Ranges,206 + Content-Range + Content-Length, or416 + Content-Range: bytes */total; the half-openslice(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
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" endReviews (4): Last reviewed commit: "chore: sync bun.lock with hono bump from..." | Re-trigger Greptile