Engine internals
How the direct-download engine in crates/velocity-transfer fetches a file. Checked against the code at commit 9a4f484 (2026-10-30, #80). The contract the manager sees is in ENGINE_CONTRACT.md, and how the engine's child process talks to the app is in ENGINE_PROTOCOL.md. The design comes from section 5 of the research report; the terms are in the glossary.
The probe (#41)
The probe is the download's first request and also its first segment, so its bytes are kept. It is GET with Range: bytes=0-(S−1), where S is DownloadOptions::probe_bytes. That's 4 MiB in the engine, the report's starting value for #47 to tune.
No HEAD: a HEAD request is never sent, and
Accept-Rangesis never taken as proof of range support.No encoding asked for: no
Accept-Encodingis sent. An encoding the server applies anyway is kept, its bytes stored as sent (changed files).Decided before any byte: the final response is classified in libcurl's header callback (
probe.rs). An answer the engine refuses is aborted there, so none of its body is written.
| Final response | What the engine does | Mode |
|---|---|---|
206, Content-Range: bytes 0-b/T | Keeps bytes 0 to b, then fetches the rest as parallel segments | Ranges |
206 with fewer bytes than asked (b < S−1) | Keeps them; the rest is part of the segments | Ranges |
206, bytes 0-b/* (no total) | Keeps bytes 0 to b. It asks for the rest with one open range, bytes=(b+1)-, and repeats from the first missing byte until a 416 says the file ends there. No parallel segments | SequentialRanges |
206 multipart/byteranges | Aborts, and retries once without Range | SingleStream after the retry |
200 | Keeps this response as the whole file: no second request, nothing wasted | SingleStream |
416 with bytes */0 | An empty file | Empty |
401, 407 | Fails: the server or proxy requires authentication. Never retried | — |
429, 503 | Throttled: retried after the wait Retry-After asks for (retries) | — |
Other 4xx | Fails, never retried (408 is retried) | — |
Other 5xx | Retried after a wait | — |
| No answer: the connection failed | Retried after a wait | — |
A malformed Content-Range, a Content-Length that disagrees with it, a start other than 0, an end past the request or the total | Fails before writing, not retryable | — |
Redirects (#40)
Apollo Download Manager follows redirects itself; libcurl's CURLOPT_FOLLOWLOCATION is off. libcurl would follow one with every custom header, and it drops only Authorization and Cookie when the host changes. When a response is a 3xx with a Location, the libcurl loop drops its body, reads the next URL as libcurl resolved it (CURLINFO_REDIRECT_URL), and checks the hop (redirect.rs):
Protocols: only
httpandhttps. A redirect toftp,sftp,scp,fileor anything else is refused before any connection. libcurl'sCURLOPT_REDIR_PROTOCOLS_STRstays set tohttp,httpsas a second barrier.No downgrade: a redirect from
httpstohttpis refused, unless the user confirmed it for this download (trust.insecureRedirectConfirmed, #67; SECURITY.md).At most 10 redirects per request; the 11th is refused.
Origins: the next request carries the login and the custom headers only when it goes to the first URL's origin: its scheme, host and port. The origin is read with libcurl's own URL parser (
url.rs,curl_url), so Apollo Download Manager and libcurl agree on the host. For example,http://evil.example\@good.example/goes togood.examplefor both. The user agent and the referrer go with every request.
The same handle is reset and set up again for each hop, so nothing from the last hop carries over. A refusal ends the request as FetchError::RedirectRefused, and the download fails with a message that names the protocol, never the URL. Each segment request follows the redirects from the first URL again, so a link that redirects to a short-lived URL works for every segment.
File names (#40)
A spec without a fileName downloads into its folder, and the server's first answer names the file:
The
Content-Dispositionheader (RFC 6266):filename*(RFC 8187, UTF-8 or ISO-8859-1) wins overfilename. Any path in it is dropped, so../../x.shgivesx.sh.Else the final URL's last path segment, after redirects, percent-decoded.
Else
download.
The name is then sanitised (naming.rs, ported from V1): separators, control characters and < > : " | ? * become _, leading dots and trailing dots and spaces go, Windows device names such as CON.txt get a _ in front, a name ending in .vdmpart gets one at the end, and the name is cut to 240 bytes, keeping its extension. When the name is taken by a file, a link or a part file, the first free name (n).ext is used, n from 1 to 9999 (PO decision on #40), so no file is ever replaced.
No extra request: the part file is created when the probe's first byte arrives, or when the probe ends without a body, as for an empty file. Before that nothing is on disk; a pause then keeps nothing, and the next attempt asks again.
Reported: the engine reports the name in the snapshot's
fileName. From then on, retries, pauses and cancels use that file.Given names: a spec that gives
fileNameis saved under it, and it fails withFileExistswhen the name is taken; #31 adds the conflict settings.naming::file_name_for_linkdecides whether a link names its file on its own:report.pdfdoes,download.php?id=3and/files/12345don't, so those are left to the server. The manager uses it throughnaming::name_for_downloadwhen a download is added (#31).
FTP and FTPS (#51)
ftp:// and ftps:// links download through the same engine as HTTP. FTP has no response head, so the libcurl layer builds one from the server's control replies before the first body byte (ftp.rs). The probe, the gate, the validators, the checkpoints and the completion gate then work as they do for HTTP.
The head from the replies
libcurl passes FTP's control replies to the header callback (213 1234 for a SIZE), and only its debug output shows which command each reply answers. The libcurl layer turns debug output on for FTP links only, reads the verb of each command sent (SIZE, MDTM, REST, RETR), and pairs it with the next reply. It keeps nothing else from that output, so the PASS line never leaves the callback.
| What the replies say | The head | When |
|---|---|---|
RETR started (150 or 125), no range asked | 200, Content-Length from SIZE | At the RETR reply |
RETR started, a range from byte a, SIZE gave T | 206, Content-Range: bytes a-b/T, where b is the range's end or T−1 | At the RETR reply |
A range from byte a > 0, and SIZE says T ≤ a | 416, Content-Range: bytes */T. libcurl doesn't retrieve such a file, and the fetch ends without a body, like an HTTP 416 | At the SIZE reply |
| Byte 0 of an empty file (T = 0) | 416, bytes */0: an empty file, as for HTTP | At the RETR reply |
REST refused (any reply but 350) | 200: the server can't start part way | At the REST reply |
A range, but no size (SIZE refused) | 200 without a length: nothing proves the range | At the RETR reply |
MDTM gave a time | Last-Modified, with a Date of now | Added to the head |
RETR failed: 550 | No head; the fetch fails as NotFound | — |
A range is
REST: the start is sent asREST(CURLOPT_RESUME_FROM_LARGE), and the range's end is the fetch's body limit, which stops the transfer there.No byte from another offset: a
200for a range after byte 0 is aborted before any byte, whatever the gate says, because its bytes would start at theRESToffset and not at byte 0. A body that arrives before any head is refused too.The date as a validator:
MDTM(RFC 3659 §3) is in UTC, to the second. With the head'sDateof now, it is a strong date once it is at least a second old (changed files), so a resume is checked against it asLast-Modifiedis for HTTP. A file whose time changed since its checkpoint starts over. WithoutMDTM, a resume is kept on its size alone and shows "not verified" (ADR-0010). FTP has nothing likeIf-Range: the time is compared in the gate, before any byte is written.
One connection, one stream
An FTP download uses one connection, whatever is asked (PO decision on #51): each FTP transfer opens a control and a data connection, and nobody has measured yet whether more help (#77). set_connections returns 1 for it. DownloadOptions::one_connection makes the probe ask for the whole file, so a download that isn't cut is one transfer. What a cut, a pause or a crash leaves is one lease to the end, so one REST. A server that refuses REST sends the whole file again, and only the missing bytes are written (RangesLost). The per-host cap counts FTP on port 21 and FTPS on 990 unless the link names another.
Logins and encryption
The login comes from the spec's
credentialsand goes out as FTP'sUSERandPASS. Without one, libcurl logs in asanonymous. A link with a login in it is refused by the engine; the app splits it off first and never stores it (database::without_login). FTP has no redirects, so the origin rules don't apply.Plain FTP sends the login and the file in clear. The engine reports what it can encrypt per protocol (
ProtocolCapabilities::encryption), and the Add download form showscleartextWarningfromsrc/capabilities.tsbefore it starts a plainftp://download, with the choice to require encryption (#60, PO decision on #51).Explicit FTPS: a download that requires encryption (
TransferSpec::require_encryption) asksAUTH TLSbefore the login, thenPBSZ 0andPROT P, so the data connections are TLS too (CURLUSESSL_ALL,CURLFTPAUTH_TLS). A server that refuses fails the download at once, asEncryptionRefused, before the login is sent. There is no fallback to plain FTP.Implicit FTPS: an
ftps://link is TLS from the first byte, on both connections.Certificates are verified as for HTTPS, with the platform's trust store (SECURITY.md). A refused one fails at once, before the login.
Passive mode only, to the control connection's address: the address in a
PASVreply is ignored (CURLOPT_FTP_SKIP_PASV_IP), so a server can't send the data connection to another host.An
http://link that requires encryption is refused (InvalidInput): it can't be encrypted.
Failures
| What happened | Kind | Retried |
|---|---|---|
The file isn't there (550) | NotFound: "The file wasn't found on the server." | No |
The login was refused (530), or access to the file | AccessDenied | No |
Encryption is required, and AUTH TLS was refused | EncryptionRefused | No |
| The data connection broke part way | Interrupted | Yes, from the first missing byte, with REST |
FTPS: the evaluation #51 asked for
#51 asked for a written evaluation of FTPS with a recommendation. The PO decided to build it in #51, explicit and implicit, and #22, which the issue names, is the closed parity inventory, so the evaluation is here.
Certificate verification (verified): FTPS uses the same TLS stack as HTTPS: Schannel with the Windows store on Windows, OpenSSL on Linux. The tests run explicit and implicit FTPS against the test CA of #69 on both, and a refused certificate, an untrusted issuer or an expired leaf, fails before the login. The FTP server presents a
TestPkileaf, with its revocation list served, as the HTTPS front does.Server compatibility (partly verified): explicit FTPS with
AUTH TLS(RFC 4217) is the common form, and implicit FTPS on port 990 is the older one; both work against the test server. Not verified: some servers require the data connection to resume the control connection's TLS session (vsftpd'srequire_ssl_reuse, for example). libcurl reuses TLS sessions by default, but the test server doesn't require it, so that combination has no test.CCC(dropping TLS after the login) isn't used.Cost (not measured): each FTPS transfer adds a TLS handshake on the control connection and one on the data connection. No measurement exists (ADR-0009); #77 measures.
Recommendation: as built. Require encryption wherever the server offers FTPS, and keep plain FTP for servers that don't, with the warning.
SFTP (#53)
sftp:// links download on russh, in crates/velocity-sftp (ADR-0013). The engine fetches through one interface, velocity_curl::Transport, so SFTP shares everything after the connection with HTTP and FTP: the probe, the gate, checkpoints, retries, speed limits and the completion gate.
One transport interface
Transport: the engine's downloads take a&dyn Transport. In the engine, a router sendssftp://links to the SFTP transport and every other link to libcurl's loop.FetchPipe: a transport other than libcurl writes its fetch through one, with the same events as libcurl's (a head through the gate, the body, then the end). It has the same reader's buffer, speed limits and body limit as libcurl's write callback; both use oneSink(velocity-curl/src/pipe.rs). A range's end is the body limit.file_head: the head an HTTP server would send for a file of a known size and time (velocity-curl/src/head.rs), shared by FTP and SFTP:200,206for a range,416past the end,Last-Modifiedfrom the file's time with aDateof now.
A fetch
Each SFTP fetch is a task on the transport's own tokio runtime (2 worker threads):
Connect, within 30 s (as libcurl's connect timeout), to the link's host and port, 22 by default.
The host key is checked against Apollo Download Manager's known_hosts file before anything else is sent (below).
Log in as the user of the spec's
credentials: withsshKey(a private key file and its passphrase) when the spec has one, otherwise with the password.statgives the file's size and modification time, which make the head. The gate decides before a byte is read.Read from the range's start, 64 KiB at a time, through the
FetchPipe. A full buffer waits for the reader; a speed limit without tokens is checked again every 10 ms.
Cancelling the fetch, or dropping it, ends the task at its next step.
One connection, one stream: as for FTP (
DownloadOptions::one_connection), until #77 measures whether more help. A cut or a pause resumes at its offset with one read from there.The resume check: the file's time is a strong
Last-Modifiedonce it is a second old, so a resume is checked against it (changed files). A file whose time changed starts over.
Host keys
The store:
<data folder>/known_hosts(Paths::known_hosts), Apollo Download Manager's own, never~/.ssh/known_hosts. It is unhashed, one OpenSSH line per host ([host]:port key-type base64-key), and compares the whole key. A host is shown by its key's SHA-256 fingerprint (SHA256:…, as OpenSSH prints it). Writes go to a temporary file, then a rename.Unknown key: refused before the login. The download fails at once, permanently, and
TransferError.hostKeycarries the host, port and fingerprint for the user to check. The transport remembers that key.Trusting it:
DirectTransfer::trust_host_key(host, port, fingerprint)pins the key the transport saw with that fingerprint, and only that one: a fingerprint it didn't see isNotFound. The caller then retries the download. The app'strust_host_keycommand (#58) calls it throughManager::trust_host_key, and the window's host-key question (#60) shows the fingerprint and calls it.Changed key: always refused, before the login.
hostKey.pinnedholds the pinned key's fingerprint. Such a key is never offered for trust, andtrust_host_keynever replaces a pinned key (InvalidState).Host certificates are refused: Apollo Download Manager trusts no SSH certificate authority.
Failures
| What happened | Kind | Retried |
|---|---|---|
| The host key is unknown, or changed | Permanent, with hostKey | No |
| The login was refused, or there's no user name | AccessDenied | No |
| The private key can't be opened (a wrong passphrase, for example) | AccessDenied | No |
| No such file | NotFound | No |
| The connection was refused, or broke part way | Connect, Interrupted | Yes, from the first missing byte |
Metalink (#54)
A Metalink document lists files with their sources, hashes and piece hashes: Metalink 4 (RFC 5854, .meta4) or Metalink 3 (metalinker.org 3.0, .metalink). libcurl dropped Metalink in 7.78.0, so Apollo Download Manager reads it itself. The manager does the reading, in velocity-core (metalink.rs); the engine only downloads the files, with the hashes it already checks (#46). Metalink is off until the user turns it on, as V1's advanced.enable_metalink (ADR-0005); the setting is stored and shown by #30.
Reading a document
The parser is quick-xml (PO decision on #54), read as a stream. Its limits are in SECURITY.md.
Per file: its name, which may hold subfolders (
disc/image.iso); its size; the strongest whole-file hash of MD5, SHA-1, SHA-224, SHA-256, SHA-384 and SHA-512; the strongest piece list, which must fit the size; and its sources.Sources:
http,https,ftpandftpsURLs without a login, best first. Metalink 4's lowerprioritycomes first and Metalink 3's higherpreference; unranked ones come last, in document order. A duplicate counts once. SFTP sources are dropped (a document can't supply a login or confirm a host key), as are torrents (metaurl, a Metalink 3bittorrentresource; a non-goal of #54).A file with no usable source is skipped and counted (
Metalink::skipped), and a document with none at all is refused.Not supported, as in V1: Metalink/HTTP (RFC 6249), whose
LinkandDigestheaders aria2 never read.
Adding its files
Manager::add_metalink adds the selected files, all of them unless a selection names some, one download each with kind metalink:
The folder: each file goes into its subfolders inside the chosen folder (PO decision on #54: kept, as V1 did). They're created one level at a time. A folder already there is used, but a file or a link (a symbolic link, or on Windows a junction or mount point; SECURITY.md) in the way is refused, never followed.
The download: its sources the engine can fetch, best first, in
TransferSpec::urls; its hash inchecksumand its piece list inpieces; its name infileName. No login, headers or referrer: a document's sources are other servers.All or nothing: a file with no source the engine supports refuses the lot before anything is added. A file the engine refuses after others were added takes those back (they're cancelled), and the refusal is the error.
A local file:
metalink::read_filereads a.metalinkor.meta4file, as V1'svalidate_local_metadata_filedid: an absolute path to a plain file, never a link. Not wired: no command calls it, so the app can't add a local Metalink file yet; Metalink downloads come only from links.
Following a link
As aria2's follow-metalink did for V1: while Metalink is on, a completed HTTP or FTP download that is a Metalink file adds every file it lists, into its own folder.
How it's known: its name ends
.meta4or.metalink, or the server called itapplication/metalink4+xmlorapplication/metalink+xml(TransferSnapshot::content_type,contentTypeon the wire, contract 11).The Metalink file stays on disk as a completed download.
One that can't be used fails, with the reason: "Apollo Download Manager downloaded this Metalink file but couldn't add its files: …". The file is kept.
Never twice: a download that came from a Metalink is never followed, so one document can't chain to another.
Mirrors (#55)
A download can have several sources: its URL and its mirrors, such as a Metalink file's (#54), in TransferSpec::urls, best first. Several are used at once, as V1's aria2 did (uri-selector=feedback; PO decision on #55). download::download_from takes them; crates/velocity-transfer/src/sources.rs keeps each one's health. A download with one URL takes exactly the path it took before.
Which sources may share a file
Bytes from two sources are mixed only when they are proven to be the same file. A mirror's first answer to a range decides; until then it serves nothing (sources::admission, checked in the request's header callback before a byte is written):
Always: the same total and the same
Content-Encodingas the download's first answer. Another total or encoding disables the mirror for this download.With trusted piece hashes: that is enough. Every piece is checked as it is covered, so a mirror that sends other bytes is caught (below).
Without them: it must also give the same strong validator (an entity tag, or a strong date) as the first answer. A mirror that doesn't is unproven: it serves no range, and is kept only as a fallback.
After that, each mirror is held to its own first answer, as the first source is (#45), with its own
If-Range.Only HTTP and HTTPS sources share. FTP and SFTP run on one connection (#51, #53), so they are only ever a fallback; a download whose first source is FTP or SFTP uses one source at a time.
Who gets the next range
For each new request the pool picks a usable source that isn't cooling down and has room (at most the download's connections each):
One never asked yet goes first, once, so its speed gets measured.
Otherwise the one with the most speed to spare: its measured speed (bytes over the time its requests were busy) divided by its open requests plus one, halved for each failure. A source tried but not yet measured counts as fast as the fastest measured one. Ties go to the earlier source.
So two equal mirrors share a file about evenly, and one twice as fast takes about two thirds of the requests. With mirrors and piece hashes, the probe and every lease end on a piece boundary, so each piece comes from one source.
When a source fails
A mirror's failure is the mirror's, never the download's: a refused or reset connection, an error status, a cut-off or short answer, a
200for a range, or an answer whose file changed. Its range is leased again, at once, from another source.A transient failure cools the source down for 2 s, doubled for each further one (
sources::COOLDOWN); the third disables it for the download (MAX_FAILURES). A permanent one, such as a404, disables it at once. Both are starting values for #77 to measure.A bad piece is blamed on the source that sent it (
Sources::blame): its standing halves and it cools down, and a second bad piece disables it. The piece is fetched again, from the healthiest source. When a piece came from several sources, which can only happen when a cut-off lease's rest went elsewhere, each gets an ordinary failure instead.The first source failing its probe, or every proven source failing, hands the download to the next source that isn't disabled. The download then starts over from it, since nothing proved its bytes the same, and the others must prove themselves against it again (
Sources::make_lead).None left: the download fails with "Every source of the download failed; the last: …", and that failure's class decides whether the engine retries it.
What a mirror is sent
The user agent and the referrer go to every source. The login and custom headers go only to the first URL's origin (#40), so a mirror elsewhere never sees them (SECURITY.md). The engine checks every URL in the spec as it checks the first: one it may not fetch refuses the download.
What the report says
DownloadReport::sources gives each source's URL, state (usable, unproven or disabled, with why), the bytes it served and its failures. Checkpoints name the source the download led with; a resume leads with that one again.
Later segments: the range scheduler (#42)
After a probe that gave a total, the rest of the file is fetched with leases from the range scheduler (scheduler.rs, report section 5.2). The downloader's thread owns it and makes every change; no lock is held across I/O.
Leases
A lease owns [cursor, end) of the file alone; [start, cursor) is already written. Its id is never reused within a download.
| State | Meaning |
|---|---|
| Assigned | The request Range: bytes=start-(end−1) was made; no answer yet |
| InFlight | The answer was checked and accepted in the header callback (check_segment) |
| Completed | cursor == end |
| Failed | It ended early; the rest of its range is unassigned again |
| Revoked | It was cancelled; the rest of its range is unassigned again |
Assignment: a free connection gets the front of the unassigned bytes, an equal share per free connection. The share is rounded up to
alignand is at mostmax_lease;Scheduler::ask_oncelifts that cap once a server throttled (#247). Sharing spreads a file over the connections without splits.Acceptance: bytes are accepted only from the lease that is in flight, at its cursor, and only up to its current end. Bytes for a lease that isn't live (ended, revoked, unknown), at another offset, or past its end are dropped and counted as wasted. They enter coverage only once the write to the part file has returned.
Split (work-stealing): a free connection with nothing unassigned takes half of the live lease with the largest remainder. The remainder must be at least
2 × min_split, and the midpoint is rounded down toalign.The old lease shrinks to end at the midpoint.
Its request is told to stop there:
FetchLimit::stop_atin the libcurl layer delivers bytes up to the limit, then stops the transfer, and reports that as a completed fetch, not an error.Over HTTP/1.1 its connection closes. Over HTTP/2 only the stream is reset, and the connection is kept (tested in #47).
Retries: a lease that ends before its end fails, and the rest of its range is handed out again under a new id. A late callback from the old request carries the old id and is refused.
| Size | Value | Where it comes from |
|---|---|---|
max_lease | 32 MiB | Report section 5.2, a hypothesis. Kept after #247 measured no cap (2026-11-04, the Ubuntu 26.04.1 server image, 2.9 GB): 294 s and 326 s with the cap, 385 s without, where the ramp settled on 1 connection because each connection it added had to split a running request. A server that throttled is asked once per connection for the rest instead (Scheduler::ask_once) |
min_split | 1 MiB (a remainder of at least 2 MiB splits) | A starting value; the report names no number |
align | 64 KiB | Report section 5.2 |
Answers that aren't what was asked
Each answer is checked in the header callback (check_segment):
Ranges lost: a
200is aborted before its body is written. When the live leases end, the rest of the file comes as one stream that writes only the missing bytes, so the file is never mixed.Anomaly: an answer that contradicts its request, such as a wrong or malformed
Content-Range, a length that disagrees with it, or a multipart body. It also covers:a body cut off before its first byte;
a chunked body that stopped short of its
Content-Range;a body that ran past its range.
The first anomaly is tolerated, and the lease's rest is leased again. At the second in a row (
MAX_ANOMALIES), no more leases are handed out, and the rest comes as one stream that writes only the missing bytes (RangesLost). A request that brings good bytes clears the count, so anomalies far apart on a long download never add up (#333). The stream starts at byte 0, so it reads everything already on disk before it can fill a gap, and showsnoRangesmeanwhile.Short answers: a
206with fewer bytes than asked is honest (RFC 9110 §14.2). Its lease fails, and only the remainder is leased again.Failure: an error status, or an answer about another file (changed files). An error status worth retrying frees the connection for a wait, and the rest of the lease is leased again after it (retries).
Invariants
Checked after every change in debug builds (Scheduler::check), and by the property tests:
I1 Partition: the coverage C, the live leases'
[cursor, end)and the unassigned bytes U are disjoint and make up[0, T).I2 Exclusivity: no two live leases overlap, and none overlaps C.
I3 Acceptance: a byte at offset p is written only by the live lease whose cursor is p.
I4 Monotonic coverage: C only grows within an epoch;
new_epochstarts again from an empty C.I5 Durability: the durable coverage D is part of C and was synced before it was recorded (
synced). Saving D in a checkpoint is #43.I6 Completion: C covers the file and no lease is live (
is_complete).I7 Conservation: bytes received = growth of C + wasted bytes.
Accounting
Every received byte is either written into the part file once, or counted in wasted_bytes. That includes bytes past a lease's end, bytes for a lease that isn't live, bytes the single stream sends again, bytes already covered, and the bytes of a piece that failed its hash and was fetched again (integrity). Progress counts written bytes only. A server that ignores ranges costs exactly one GET with no wasted bytes, which a fixture test checks.
Progress and speed (#246).
When progress is sent: the engine sends a progress snapshot for every 256 KiB written (
PROGRESS_STEP), and at once when the count goes down because the download started over.The speed: the snapshot's download speed is the bytes written over the last 3 seconds (
SpeedMeterincrates/velocity-transfer/src/speed.rs). That's a measurement, not an estimate. The list, the details panel, the time left and the status bar's total all show this figure.Measuring starts again at the first report after a start or resume, and when the count goes down. So the bytes already on disk never count as received.
No figure is shown until 0.5 s has been measured.
Why 3 seconds: a change in speed, such as a new speed limit, shows half within 1.5 s and fully within 3 s. A pause in one connection barely moves the figure.
Before #246, the speed was measured over a single 256 KiB step, about 80 ms at 3 MiB/s, and it jumped between KiB/s and GiB/s.
Changed files (#45)
Every answer after the first must be about the file the first answer described (report section 5.3, validator.rs). Validators belong to the file, not to its URL, so an answer that a redirect brought from elsewhere is held to the same.
The validator
| The first answer gives | Sent as If-Range on every later range request |
|---|---|
A strong ETag, such as "v1" | The ETag |
A weak ETag, such as W/"v1", with or without a Last-Modified | Nothing. RFC 9110 §13.1.5 forbids a weak tag in If-Range, and a date while an entity tag exists |
No ETag, and a Last-Modified at least 1 s before the answer's own Date | The date. 1 s is RFC 9110 §8.8.2.2's rule for a client (STRONG_DATE_MARGIN); the report keeps a larger margin as a hypothesis to test |
| Anything else, a malformed ETag included | Nothing. Only the total is compared, and a resume follows ADR-0010 |
The report's fixture table says a weak ETag still allows a strong Last-Modified. The RFC's text doesn't, and the engine follows the RFC.
What every later answer is checked for
In the header callback, before any byte is written, an answer is about another file if it has:
another ETag, compared as sent, so a weak tag never equals a strong one, or no ETag where the first answer had one (the report's conservative rule);
another strong date, or none;
another total, or a
416for a range inside the old total;another
Content-Encoding, which is its own case below.
A 200 to a range that carried If-Range is the new file when its validators differ (RFC 9110 §13.1.5), and ranges lost when they match. The same checks apply to the stream that fills the gaps after ranges are lost, and to a probe asked for again after it was cut off, whose bytes so far are the earlier answer's. That retry must also give the same length.
What a change does
Another file: every request stops, the part file is emptied, and the download starts over once from a new probe, which gives the new validator. Old and new bytes are never mixed. A resume whose file changed since its checkpoint does the same.
Another encoding: ranges count encoded bytes, so the download starts over once as one stream (
Mode::SingleStream).A second change after the start-over ends the download: "The file changed on the server during download, twice: …". It is Transient and retryable, so the user can retry it, but the engine doesn't restart it on its own. The part file is deleted.
The start-over uses a new scheduler and a new probe, not Scheduler::new_epoch, because the new file's total and validator come from the new probe.
Content encoding
A server may apply an encoding nobody asked for, such as a .tar.gz served with Content-Encoding: gzip. The engine stores the bytes exactly as sent and never decodes them (PO decision on #45, replacing #41's refusal). Ranges work while every answer has the same encoding, which the checkpoint records (contentEncoding) so that a resume can check it. The rare cost: a file that a server compressed on the fly is saved compressed.
Expired links
A 403 on any answer after the first fails with "The download link expired. Get a fresh link from the browser." (PO decision on #45: a message, with no new failure kind). A 403 on the first answer keeps "The server denied access to the file. The link may have expired.", as it may be a real refusal.
What can't be detected
A server that changes the file but keeps its ETag, its date and its length looks unchanged. Only a checksum catches it (integrity). Without any validator, only the total is compared (ADR-0010).
Retries, waits and stalls (#48)
A failure is classified first (failure.rs), and the retry policy then decides from its class alone (retry.rs). The messages use V1's words where V1 had them.
| Class | Examples | What happens |
|---|---|---|
| Transient | No answer, or a connection broken during the request; a name that doesn't resolve; no network on this computer; a refused connection; the 30 s connect timeout; a failed TLS handshake; 408; other 5xx; a stall | Retried after a wait |
| Throttled | 429, 503 | Retried after at least what Retry-After asks for (seconds or an HTTP date), up to 10 minutes. Without Retry-After, at least 60 s, as in V1. The download's connections are halved, and kept to no more than the requests the server was still serving, down to one. The engine remembers that count for the host (Opening). From then on each connection asks once for the rest of its share (Scheduler::ask_once, #247). A wait of 30 s or less stays Downloading, with no error shown (PO decision on #247). When the server then refuses connections (the connection is refused or times out), the wait is 60 s, doubled for each refusal in a row, up to 10 minutes: "The server is refusing connections from this computer for now, after asking Apollo Download Manager to slow down." A name that doesn't resolve, or no network on this computer, is never taken for that refusal: it's retried as a transient failure (#335) |
| Permanent | 401, 403, 404, 410, other 4xx; an untrusted certificate; too many redirects; a protocol outside the allowlist; an answer that contradicts itself; a full disk, or a folder Apollo DM may no longer write to | Never retried. A full disk or a refused write checkpoints what reached the disk when a sync still works, and keeps the part file whenever a checkpoint exists, so the user's retry, once it is fixed, resumes (#74) |
No network on this computer (#335): libcurl reports every failed connect as error 7. The libcurl layer reads the OS error behind it (CURLINFO_OS_ERRNO). No route from this computer (WSAENETDOWN, WSAENETUNREACH or WSAEHOSTUNREACH on Windows; ENETDOWN, ENETUNREACH or EHOSTUNREACH on Linux and macOS) makes it CurlFailure::NoNetwork. A refusal, or no OS error at all, stays Connect. Verified on Windows 11 with the engine's command line: a link to 2001:db8::1, on a network without IPv6, gives NoNetwork, and a closed local port gives Connect. Not verified on Linux or macOS.
Inside a download
What is retried: the probe, each range segment, each sequential range and the single stream.
A failed segment frees its connection for the wait. A new lease then takes the rest, under a new id. The other connections go on in the meantime.
The wait: it doubles from 1 s up to 60 s, with full jitter, which means a random wait between zero and that value (
fastrand).Bounded: a download gives up after 5 failures in a row with no new byte in between. A retry that brings new bytes starts the count again, so every retry either makes progress or brings the end closer.
The probe: a probe whose answer gave the file's total and was then cut off isn't retried. Its bytes are kept, and the range scheduler fetches the rest.
A connection cut after it brought bytes isn't an anomaly and costs no retry: the rest of its lease is leased again at once (#333). Its bytes are progress, and a server reset can cut every connection at the same moment. Counted as an anomaly, as it was until #333, two cuts sent a 115 GiB download back to byte 0 as one stream, and its progress froze at 24.6 GB.
A connection cut before its first byte is an anomaly, because nothing shows that ranges work there. A stall and a connection that fails before any answer count as failures, not anomalies.
Stalls
The rule: a stall is time in which a request brings too little from the network: no byte, or less than
RetryPolicy::least_rate_bps(1 KiB/s) over the stall time, so 15 KiB in 15 s (#292, PO decision on #257). A connection that trickles a few bytes now and then is therefore stalled, as V1'slowest_speed_limit_kibhad it. A slow but steady transfer above the floor is never reset.Stalled after 15 s: with too little, the download shows Stalled ("No data received"). With several connections, it shows Stalled only when every one of them brings too little. A few bytes don't end a stall; a request that brings the floor again does (
Segment::Moving,Control::moving).Reset after 30 s: with too little, the request is cancelled and retried like any failure. These replace the prototype's fixed 60 s limit. The floor is a starting value, not a measurement; it isn't a setting.
A speed limit is never a stall: while a speed limit applies to the download, its own or the global one, any byte counts as progress, so a request the user slowed is never reset. A limit set while a request runs applies to its next request.
Disk waits don't count: the clock (
Slowness) runs only while the forwarder or the single-stream reader waits for libcurl. Time spent waiting for a slow disk is never taken for a stall (engine-mode-costs.md measured disk stalls of up to 57 s).
The whole download
A download that still fails in a way worth retrying is started again by the engine (V1's app_retries):
unless no try of that start reached the server: a name that didn't resolve, no network on this computer, or a connection refused or timed out. Those 5 tries are all, and the download fails (
DownloadError::Unreachable, PO decision on #245). A server that accepts the connection and doesn't answer is still started again;at most 2 times; then, for a transient failure, again while it has had no new byte for less than 5 minutes (
RetryPolicy::ride_out, PO decision on #317), at least every 60 s, so a Wi-Fi drop, a router restart or waking from sleep is ridden out;after 5 s and then 10 s, or 60 s and then 120 s when the server was throttling, never more than 15 minutes;
from its last checkpoint when it has one (its part file was kept), otherwise from the start.
While it waits, the snapshot shows Retrying, with the failure's message and "Retrying automatically (1 of 2).", and past the 2 "Retrying automatically for up to 5 minutes.". A request's own retry within a start shows its failure too, with "Trying again (2 of 5).": the try that comes next, of the 5 a start makes (#245). Once bytes come again, the error is cleared. A cancel during the wait removes the kept part file. A permanent failure is never started again. Retrying by hand is the manager's, and never a second automatic retry (ENGINE_CONTRACT.md).
The values
All of them are starting values to tune by measurement, not requirements (PO decision on #48). RetryPolicy::DEFAULT holds them, and the connect timeout is LoopOptions::connect_timeout. The user's engine settings replace them (set_engine_settings, transfer contract version 14; SETTINGS.md, #30), for downloads that start or retry after a change. The engine in the app's process also takes a policy with InProcessEngine::start_with.
Statuses
Control::with_status reports Retrying while a download waits, with when it tries again (#59), Stalled while no byte comes, and Downloading when bytes come again, each change once. With several connections, Retrying shows only when every connection waits, and its time is the first to try again. A restart of the whole download reports its own time, never the last request's. The engine puts them in the snapshot. An engine that hangs without exiting is the supervisor's (ENGINE_PROTOCOL.md).
Speed limits and back-pressure (#49)
Speed limits
Two limits per transfer: each transfer has its own limit (
TransferSpec::limits, changed byset_limits), and every transfer also draws from the engine's global limit (set_global_limits). The lower of the two applies.Sharing: whatever the global allowance a transfer doesn't use goes to the others. With a global 4 MiB/s and one download held to 1 MiB/s, another download gets the remaining 3 MiB/s.
Token buckets (
rate.rs): each limit is a token bucket,RateLimit. It refills at its rate, and keeps at most 250 ms of unused allowance (a starting value).Each request of a download draws from its download's bucket and the global one.
When a bucket has no tokens, libcurl's write callback pauses the transfer (
CURL_WRITEFUNC_PAUSE), and libcurl keeps the bytes.The libcurl loop thread checks every 10 ms, and resumes the transfer once all its buckets have tokens again.
A write is taken whole while a bucket has any tokens. So a bucket owes at most one write (16 KiB), and later writes wait until it is paid back.
Fair turns: resuming a transfer hands it libcurl's kept bytes at once. So the order of resuming turns each round, and no transfer always comes last to a shared bucket.
Live changes: a limit's rate changes at once. The next write sees it, so a change applies within one loop round (10 ms).
Why not libcurl's own cap:
CURLOPT_MAX_RECV_SPEED_LARGEis "not an exact science" by its own documentation, and its documentation doesn't say whether it can change during a transfer (report open question 3).The smallest limit is 1 KiB/s, V1's unit. A lower one is refused with
INVALID_INPUT. A low limit is bursty, as data arrives in writes of up to 16 KiB, but its average holds.HTTP/2: a paused stream may still receive up to one stream window of data, which libcurl keeps (curl_easy_pause documentation). The HTTP/2 policy is in Connections.
After a restart: the supervisor gives every new engine the global limit the old one had. A transfer's own limit is in its spec, which the app passes again when it resumes the transfer.
Measured on the local test server (Windows 11, tests/limits.rs):
| Case | Expected | Measured |
|---|---|---|
| Global 4 MiB/s, 3 downloads, over 10 s, 8 runs | 4.00 MiB/s | 3.997 to 4.003 MiB/s |
| The same over 30 s, by hand | 4.00 MiB/s | 4.000 MiB/s |
| 0.5 MiB/s raised to 4 MiB/s, the 2 s after the change, 5 runs | 4.00 MiB/s | 3.975 to 3.983 MiB/s |
| 1 MiB/s download under a global 4 MiB/s, 5 runs | 1.00 MiB/s | 1.022 to 1.031 MiB/s |
| The other download, 5 runs | 3.00 MiB/s | 2.972 to 2.978 MiB/s |
Back-pressure
A slow disk slows the network instead of growing memory. This was built in #37 and #38 and is now measured:
Per request: each request holds at most
LoopOptions::buffer_bytes_per_fetch(1 MiB) for the writer. When that buffer is full, libcurl's write callback pauses the transfer, and the reader resumes it once half is free.The queue: at most 2 events per connection wait between the requests and the writer.
Measured:
DownloadReport::peak_buffered_bytesreports the most one request held at a time. The slow-writer test stalls the writer for 2 s, and checks that each buffer filled and never went past its cap by more than one 16 KiB write.The kernel's share: the sockets' own buffers belong to the kernel. They hold more, depending on the system, and on Windows they kept growing during a pause (#147, #150). Only Apollo Download Manager's own buffers are checked exactly.
Connections (#47)
How many connections a download uses, and how they are shared. The PO decisions are on #47.
What a download may use
A download's connections are the tightest of these, from the outside in:
| Limit | Where it comes from | Shown as |
|---|---|---|
| What it asked for | TransferSpec::max_connections, or set_connections later, at least 1 and at most the per-host cap | requested |
| Its share of the per-host cap and the shared budget | The engine, max-min fairly between the running transfers (budget.rs) | perHostCap, sharedBudget |
| A throttling server | A 429 or 503 halves the connections, keeps no more than the server was serving, down to one (#48, #247); remembered for the host while the engine runs | serverThrottling |
| What helps | Opening a step at a time (Opening), then the adaptive ramp (adaptive.rs) | rampingUp, noGain, speedLimit |
| What is left to split | No remainder of at least 2 × min_split | fileSize |
Snapshots carry the connections in use now (Progress::connections) and connectionDetail: requested, allowed and the tightest limit, limitedBy (CONTRACTS.md).
Live changes
set_connectionschanges the count while the transfer runs, without a restart: no new probe, and the part file and its coverage stay as they are.More take new leases at the next step of the download, splitting a live lease when nothing is unassigned.
Fewer stop the newest requests. Each stops at its next write (
stop_at(0)), the bytes it already delivered are kept, and the rest of its lease is leased again. The fixture checks that its changes waste no byte.A paused transfer keeps the count for when it resumes.
Budgets and fairness
Per host: at most 8 connections to one host name and port, shared by every transfer to it. libcurl's
CURLMOPT_MAX_HOST_CONNECTIONSis set to the same 8, as the backstop: a fetch past it waits for a free connection.Shared budget: at most 48 connections across all transfers.
Max-min fair: the engine shares both again whenever a transfer starts, ends or changes its count. Connections go one per transfer per round, so no transfer gets more while another that wants more has fewer; a small download's unused share goes to the others.
One each: every running transfer keeps one connection, even past the caps, as a download makes no progress without one. How many transfers run at once is the queue's job (#50).
The budget always applies, to a count set by hand too (PO decision on #47; V1 let a hand-set count ignore it).
Adaptive connections
The asked-for count is the most a download may use, not a must (PO decision on #47). A download opens every connection it may use, a step at a time (Opening), and drops the ones that don't help (PO decision on #221):
Windows: the ramp measures the body bytes received over a window of max(2 s, 4 × RTT). RTT is the shortest wait seen between a request and its answer.
Each count is measured for 2 windows, and judged by their average speed. The first window after the count is set from outside (a new download, a ceiling that rose or fell) isn't measured: it holds the connections' start, and a test server's first burst once made every drop look like a loss (batch run 41178941474).
Drop while the speed holds: the first count measured sets the speed to keep. The ramp then drops one connection, and if the speed without it stays within 10% of the best average at a higher count, it drops the next.
Bring back the one that helped, and judge it again: when the speed without a connection falls by 10% or more, that connection comes back. The speed with it is measured afresh for 2 windows, then it is dropped and judged again. If the speed falls by 10% again, it stays and the ramp stops (
noGain). If not, the first loss was a slow stretch, and the ramp goes on dropping (#281). At one connection it stops too.Why it judges twice: on CI's Windows runner, both windows without the 8th of 8 streams came in 15% slow, at a server where the count made no difference. The 8th came back, and the ramp held all 8 for the whole 128 MiB download (main run 41637850734). A connection that helps now costs 4 more windows before it is kept.
Noise keeps connections: a slow measurement can only make the ramp keep one more, never lose one that helps. Since #281 a count must measure slow twice to be kept that way. Likewise, since #432, one more tried from a remembered count must measure fast twice before the ramp takes every connection.
Why it changed: #47 started with one connection and added one per window while each added 10%, and stopped for good at the first window that didn't. Normal internet variation could stop it below the count asked for. On 2026-10-30 the app took 319 s for the 2.9 GB Ubuntu 26.04.1 server image, the engine with a fixed 8 connections 294 s, and V1 286 s (one run each, #221).
No decision while fewer requests run than the ramp wants, at a file's tail for example: that speed says nothing about the count.
A lower ceiling (the budget, the user, a throttling server) applies at once. A higher one, such as a count the user raised, is taken at once, and the ramp judges again (PO decision on #221).
While it tries fewer, the download shows
rampingUp, a name from #47's climbing ramp.Example: asked for 8, from a server that gives one client no more than 3 connections' worth, a download drops to 2, brings the 3rd back, drops it again to judge it a second time, keeps 3, and shows
noGain.Under a speed limit (PO decision on #278), any count that reaches the limit gives the same speed, so the ramp halves the count at each step instead of dropping one: 8, 4, 2, 1. A halving that loses speed brings the dropped ones back, and from then on it drops one at a time, judging twice as above. With 8 asked for and 1 MiB/s, one at a time took about 39 s to reach 1 connection on releases.ubuntu.com (one run, 2026-11-03); halving takes 3 judgments instead of 7.
The limit as the reason: once the ramp stops at a count whose fastest window came within 10% of the tightest limit, the download's own or the global one, it shows
speedLimit, notnoGain. A limit it doesn't reach, because the server is slower, leavesnoGain. A global limit shared by several downloads gives each less than the limit, so they shownoGain.A changed limit (set, lifted, raised or lowered) takes every connection again and judges again: a download held at 1 by a limit would otherwise stay at 1 once the limit is lifted.
DownloadOptions::adaptive is None for a fixed count, which the CLI uses to compare fixed plans.
What a server allowed (#325)
What a download learns about its server outlives it, so the next download from that server starts at the right pace, after a restart too.
What is learned: the count the adaptive ramp settled at when more connections stopped helping (
Tuning::NoGain), and the most connections the server served before it throttled (#247). A count a speed limit chose says nothing about the server, and isn't kept. Keyed by the host and port libcurl counts connections by (host_of).Where it goes: the engine keeps both per host and sends
HostLearned; the app keeps them in its database (host_memory, DATA_AND_MIGRATIONS.md) for 7 days, and gives them back withset_host_memorywhen it starts. The supervisor gives a restarted engine what it last had.How a download uses it: its ramp starts at the remembered count instead of at every connection it may use (
Ramp::starting_at), and still judges from there. Before it settles at that count it tries one more, once: if that helps byhelp_percentin two measurements in a row, the server gives more now, and it takes the whole ceiling and judges again from the top. One measurement isn't enough (#432): on a busy CI runner a single fast stretch took a download remembered at 2 to all 4, the same noise #281 guards against when a connection is dropped. A remembered count can't hold a download below what the server now gives, and a server that still caps costs one extra count measured.Measured (2026-11-06, Windows 11, local per-IP-throttled server that gives no more after 2 connections, 4 asked for, 3 runs each): started fresh, the download kept 2 connections, ran at most 4 requests at once, and settled after 27.8 s in each run; started from a remembered 2, it kept 2, ran at most 3 at once (the one extra it tries), and settled after 24.2 to 24.3 s. The speed is the server's cap either way: what remembering saves here is connections and about 3.6 s of judging, not download time (
a_remembered_count_starts_the_download_there_and_one_more_is_tried_onceandadaptive_connections_stop_below_the_cap_when_more_stop_helpingprint these). A server that throttles is where it matters more: its count now outlives a restart, so the first download after one isn't refused again (#247); that case wasn't timed.
Opening (#247)
A download doesn't ask for all its connections at once (PO decision on #247):
Doubling: after the probe it opens one request, then 2, 4 and 8, each step once every request opened so far has received a byte. A fixed count and the CLI open the same way.
Fresh ranges, no splits: leases are sized for the count the download is heading for, so a later step takes unassigned bytes instead of splitting a running request, and each request ends at its own end.
At the first
429or503, the download keeps no more connections than the requests the server was still serving (at least one), and opens no more (Retries).Remembered per host: the engine keeps that count, by host name and port, while it runs. A restart, a resume or another download from that host starts there, never asks for more, and asks once for the rest of each share.
Few requests once a server throttles: after its first
429or503, each connection asks once for the rest of its share (Scheduler::ask_once), and so does every later download from that host. With 32 MiB leases a 1 GB file took about 32 requests one after another, and proof.ovh.net refused about every second one, then this computer, after about 20 (2026-11-03). V1, on the same file and PC, made about 8 requests (the whole file, then 7 equal parts, as seen against the local test server) and wasn't refused. Other servers keep 32 MiB leases: on the Ubuntu image Apollo Download Manager with them took 294 s and 326 s, V1 363 s, and Apollo Download Manager without a cap 385 s (#247).Connections of their own once an HTTP/2 server throttles: from then on each request gets a connection of its own (
Multiplex::Chosen(true)), not a stream of the shared connection. proof.ovh.net answers429to a stream that comes while another is being answered on the same HTTP/2 connection, and serves the same requests on connections of their own: 4 ranges at once on 4 connections got four206s, and the same 4 as streams of one connection got429s (2026-11-04). Before this, the retries went on as streams of the shared connection, were refused, and the server blocked the computer.Why: proof.ovh.net serves one request per client at a time, answers the rest
429within about 200 ms, and blocks a client for about 10 minutes after roughly 6 to 9 refusals in a few seconds. The old opening, all 8 at once after the probe, got the field-test PC blocked on its own (#247, 2026-11-02). Doubling costs a server like that one refusal.The cost on other servers: about three round trips to reach 8 connections. Not yet measured against the earlier timings (#221); it is to be measured on the Ubuntu ISO.
TLS sessions (#327)
A download's extra connections, and later downloads from the same server, resume the first connection's TLS session instead of doing a full handshake. libcurl does this by itself: the engine runs every transfer on one Multi (fetch.rs), and libcurl 8.23.0 shares its TLS session cache among the easy handles of one Multi. No share handle is needed (PO decision on #327).
Verified (2026-11-06):
later_connections_to_a_server_resume_its_tls_sessionincrates/velocity-curl/tests/tls.rsmakes 4 requests, each on a new connection of its own, to the TLS test front. The first is a full handshake and the other 3 resume, with TLS 1.3, on Windows (Schannel) and on Linux (OpenSSL 3.5, WSL2). The test guards it across libcurl updates.Not tested here (libcurl's documented behaviour): a session is offered only to the host, port and TLS settings it was made with, so a session from one server is never offered to another. Sessions live in memory only; nothing about them is written to disk.
HTTP/2
Multiplexing first: requests to one host wait for the connection being set up (
CURLOPT_PIPEWAIT), and share it as streams if it speaks HTTP/2.Then connections of their own, once: when the ramp finds that more streams stop helping (
noGain, notspeedLimit: under a limit a faster way can't show), the download tries requests on connections of their own (CURLOPT_FRESH_CONNECTandCURLOPT_FORBID_REUSE), starting again with every connection it may use. Not after the server throttled the download: the restart would ask while the stopped streams are still being answered, and be refused (#247).The faster way is kept: connections of their own stay only if their best speed beats the best multiplexed one by 10%. Otherwise the download goes back to the multiplexed count. Changing the way stops every request first, so each way is measured on its own.
DownloadReport::own_connectionssays which way was kept.A split resets only its stream; the connection stays.
The values
All are starting values to tune by measurement (#77); the user can change them (SETTINGS.md, #30).
| Value | Starting value | Where it comes from |
|---|---|---|
| Per-host cap | The performance profile's: 8 for Balanced, 2 to 16 for the others (SETTINGS.md) | Report section 5.5 (8, at most 16); PO decisions on #47 and #57. set_engine_limits changes it while transfers run, and libcurl's own per-host cap (CURLMOPT_MAX_HOST_CONNECTIONS) with it: with many small downloads at once, each keeping one connection, that is what bounds a host |
Smallest piece (Sizes::min_split) | The profile's: 1 MiB for Balanced | PO decision on #57 |
Shared budget (CONNECTION_BUDGET) | 48 | V1's connection_budget; PO decision on #47 |
| Window | max(2 s, 4 × RTT) | Report section 5.5 |
| Windows per count | 2, averaged | #47's 2 flat windows (report section 5.5); #221 |
| Loss that brings a connection back | 10% | #221: the gain #47 needed to add one; one connection of 8 that carries its share is worth 12.5%. 5% let noise keep a connection that didn't help on CI's Windows runner |
| Windows not measured after the count is set from outside | 1 | #221 |
| HTTP/2: gain that keeps connections of their own | 10% | Report section 5.5 |
Measured on the local test servers (Windows 11), with #47's climbing ramp. These check behaviour, not speed: no speed claim is made until #77 (PO decision on #47). #221 changed the ramp and its fixtures, and these rows haven't been measured again:
| Fixture | Expected | Observed |
|---|---|---|
| 4 downloads from one host, 8 asked for each, 2 MiB/s per connection | 2 each (perHostCap); Jain's index ≥ 0.9 | 2 each; Jain's index 1.000 in both engine modes |
| 1 MiB/s per connection, 8 asked for | Ramps up to 8 | 8 |
| 1 MiB/s per connection, 3 MiB/s per client, 8 asked for, 5 runs | Stops below the cap | Kept 3, at most 4 at once, every run |
| HTTP/2, 1 MiB/s per TCP connection, 5 runs | Connections of their own | 9 or 10 connections, 6 to 8 at once |
| HTTP/2, 1 MiB/s per stream, 5 runs | One multiplexed connection | 1 connection, 8 streams |
| 40 small files from one host | Reuse | 1 connection one by one; at most 8 at once |
The "at once" counts above are the server's. Since #154, the ramp fixtures check the engine's own count instead, DownloadReport::peak_requests.
Persistence and recovery (#43)
A download survives a pause, an app restart, an engine crash and a power loss without a corrupt or falsely finished file. The rules are in the persistence rule; the PO decisions are on #43.
Checkpoints
When: every 2 s or 16 MiB, whichever comes first (
Cadence::DEFAULT, the report's starting values). Also when a download is paused. The probe takes them too once its answer has given the file's total, because the probe can be the whole download.How, in this order:
the part file is synced (
sync_data);the scheduler marks what is covered as durable (D);
the checkpoint is sent.
A byte is in D only once it is on disk (invariant I5).
What it holds (
Checkpoint, ENGINE_CONTRACT.md):a format version (
CHECKPOINT_FORMAT);the source without its login, query and fragment, so no token;
the total;
what the first answer said about the file: its ETag, its Last-Modified only when that was the strong validator, and its
Content-Encoding(changed files);D as half-open byte ranges.
Leases are never saved.
Where it goes: the engine sends it as a
checkpointevent over its pipe. The app saves it in its database (thecheckpointstable, DATA_AND_MIGRATIONS.md); only the app touches the database (PO decision). The manager saves it, drops it once the download completes or is removed, and resumes from it when the app starts again or the engine was lost (ENGINE_CONTRACT.md).Only ranges resume. A single stream (a server without ranges) and a download without a total (sequential ranges) take no checkpoint, because nothing could resume them. Pausing them keeps the part file, and resuming starts over.
Resuming
The app hands back what it saved, in TransferSpec::resume:
resume | The engine |
|---|---|
checkpoint | Trusts the checkpoint only if it fits: this format, this download's source, and ranges that are sorted, disjoint, non-empty and inside the total. If it fits, the part file is reopened (never through a link), only D is trusted, and only the rest is fetched, with the recorded validator in If-Range. Another validator, total or encoding, or a 416 inside the old total, means the file on the server changed: the old bytes are discarded and the download starts over once. If it doesn't fit, or the part file is gone, the download starts over |
discardPartFile | The part file is the download's own but has no usable checkpoint: it is deleted and the download starts over (PO decision). A link, or anything that isn't a plain file, is refused and never touched |
| none | A new download. An existing part file is never reused: the download fails |
A resumed download's progress counts the bytes it holds: D plus what arrived since, not only the new bytes (#257). So the list shows where it really is from its first report, and progress never drops after a pause, a crash or an engine restart, unless the download started over. Before that first report, a transfer added with a checkpoint is queued with D's bytes and the file's size (TransferSnapshot::queued, used by the engine and its supervisor), not "0 B of unknown size".
A start-over is never silent (#257).
What's reported: each time a download drops bytes it had, the engine sets the snapshot's
startedOverto why. The details panel shows the sentence, and the app's log has a warning line:download <id> started over: <why>.A start that drops nothing isn't reported: for example a fresh download, or a mirror hand-over before any byte was written.
The reasons are in
download::started_over:
| Why | Sentence |
|---|---|
| Another file (a changed validator or size) | "The file on the server changed, so the download started again." |
| Another encoding | "The server changed how it sends the file, so the download started again." |
| A checkpoint that isn't trusted (above) | "The saved progress couldn't be used, so the download started again." |
discardPartFile, with a non-empty part file | "There was no saved progress for the unfinished file, so the download started again." |
| A checkpoint whose part file is gone | "The unfinished file was gone, so the download started again." |
| A bad piece from a server without ranges (integrity) | "Part of the file failed its check, and the server can't send parts alone, so the download started again." |
| The first source failing after it wrote bytes (mirrors) | "The first source failed, so the download started again from another." |
A strong validator that differs from the one the download started with always means another file, even at the same size: the download starts over once and says so, and a second change fails it. Two versions are never mixed (PO decision on #257, 2026-11-03).
StoredDownload::resume in the database layer makes the choice from a record and its saved checkpoint. It picks the checkpoint if it's known and of this record's source, discardPartFile otherwise, and nothing for a finished record. A record this build can't route is never resumed.
Pausing, stopping and crashing
Pause: the requests stop, a last checkpoint is taken, and the part file stays. Resume starts a new worker from that checkpoint. Cancelling a paused transfer deletes its part file.
When the app goes away, or closes the engine's pipe, the engine pauses every transfer and keeps their part files, so the app can resume them when it starts again. It no longer cancels them, as #39 did.
A failure: one worth retrying, after a checkpoint was taken or when the start resumed from one (#317), keeps the part file, and the engine starts the download again from that checkpoint (the whole download). Any other failure deletes it.
A crash (
kill -9, a power loss) leaves the part file and the last checkpoint the app saved. Nothing that looks finished exists until the rename.
Finishing
The file is renamed into place only when coverage is complete and, with a checksum, the hash matches (the completion gate):
the part file is sized, checked and synced;
it is renamed without ever replacing an existing file. On Windows that's
MoveFileExWwithoutMOVEFILE_REPLACE_EXISTING, and withMOVEFILE_WRITE_THROUGH. On Linux it'srenameat2(RENAME_NOREPLACE), then a sync of the folder, so the new name survives a power loss.
A file that appeared at the destination during the download is kept, and the download fails with FileExists. On a Linux file system without RENAME_NOREPLACE, a hard link does the same job. The rename, unlike #37's hard link, also works on FAT32 and exFAT drives.
Part files
files.rs holds the only operations on part files:
Creating one: exclusively, never over an existing name and never through a link. It's marked sparse on Windows (
FSCTL_SET_SPARSE), so NTFS doesn't zero-fill the gap in front of a segment (see below).Reopening one: without following a link, checked again on the open handle.
Deleting one: only if it's a plain file, never a link.
The no-replace rename: described above.
The calls go through windows-sys and libc (PO decision), each unsafe call with its SAFETY note. DownloadReport::sparse says whether this run's part file was made sparse. A file system without sparse files, such as FAT or exFAT, still works, only slower.
Integrity (#46)
A download is Completed only once its bytes are proven as far as the trusted hashes it has allow (report section 5.3, integrity.rs). Hashing never runs on the network or downloader thread.
The completion gate
In this order:
coverage is
[0, T);the part file's length is T;
every piece matches its trusted hash, when there are piece hashes, and the whole-file hash matches the checksum, when there is one. The status is Verifying while the end of the whole-file hash is computed;
the part file is synced;
it is renamed without replacing an existing file;
on Unix, the folder is synced.
The hash frontier
Hashes don't compose: the digests of separate segments can't be combined into the file's. So the frontier, on a thread of its own, hashes the covered prefix from byte 0. The part file tells it, after every write, how far the bytes from 0 reach; it reads the new ones back and hashes them. At completion it hashes whatever is left. Its state isn't saved, so after a crash or a resume it starts again from byte 0, reading back what is on disk.
The worst case is one full sequential read at completion: the first segment finishes last, as when the range after the probe is the slowest.
A checksum is never skipped: without a running frontier, the whole file is hashed at completion.
Memory: the frontier holds one 1 MiB buffer and one number, how far it may hash; the writer never waits for it.
Cancel and pause while it waits: completion waits for the frontier and for the last piece verdicts, checking every 100 ms (
CANCEL_CHECK). A cancel ends the download and removes the part file; a pause keeps it, and a resume hashes again from byte 0. Either thread stops after its current 1 MiB read.
Piece hashes
TransferSpec::pieces holds trusted hashes of fixed-size pieces, such as a Metalink file's <pieces> (#54 fills it; PO decision on #46). Hashes Apollo Download Manager computes itself would only catch disk errors, never wrong server content, so only trusted ones are used.
Checked once covered: each piece goes to the piece verifier, on a thread of its own, as soon as its last byte is written, in every mode. The last piece ends with the file, so it goes once the total is known.
Re-fetched alone: once the file is covered, the engine waits for every verdict. A bad piece is taken out of coverage, its bytes from this attempt count as wasted, and the download fetches the missing ranges again with a new scheduler built from the coverage. The range scheduler isn't changed for this, so invariants I1 to I7 hold as before.
At most 2 re-fetches per piece, 3 tries in all (PO decision on #46). A third bad copy fails the download: "The downloaded file's piece n of m doesn't match its trusted hash, even after fetching it again 2 times: the server sends bad data. …".
Without ranges (a single stream, or ranges lost), a piece can't come alone, so the whole file is downloaded once more as one stream (PO decision on #46). A bad piece again fails it.
A list that doesn't fit the file, with more or fewer pieces than its size needs, fails at once as Permanent.
With a checksum as well, the frontier starts again from byte 0 after a piece is fetched again, because it may have hashed the bad bytes.
Mirrors: a bad piece is blamed on the source that sent it, and fetched again from the healthiest one (Mirrors).
A failed check
A wrong checksum, or a piece that stays bad, fails the download with a message and DownloadError::IntegrityFailed. A piece that stays bad is Transient and retryable: another try, or another mirror, may send it right. A wrong checksum is Permanent (PO decision on #274): retrying gets the same bytes unless they were damaged on the way, so its message says to check the checksum first, and a retry by hand still downloads the file again. Either way:
the part file is kept for inspection, and not renamed;
the engine doesn't start it again on its own, as that would replace the part file;
when checkpoints were sent, a last one follows that trusts no byte, so a resume from it downloads the whole file again instead of checking the same bytes a second time (report section 5.3: "A retry re-downloads once").
A piece list that doesn't fit the file fails the same way, but as Permanent: the list or the server is wrong.
What was proven
DownloadReport::verification and the snapshot's verification say what the completed file's bytes were checked against (ADR-0010):
| Verification | When |
|---|---|
verified | A checksum or trusted piece hashes were given, and they matched |
notVerified | No hash, and the download resumed from bytes on disk without a strong validator, so a file that changed at the same length could have mixed two versions |
sizeVerified | Anything else complete: every byte arrived and the length matched |
"Verified" is shown only when a hash was checked. The manager saves the value (downloads.verification, #58).
Read-back cost
Measured with examples/hash_readback.rs (the issue's risk; #46, 2026-10-23) on Windows 11 with an Intel Core i7-13620H (16 threads, 16 GB). A 1 GiB file is written in 1 MiB chunks to a part file, and the frontier follows. Medians of 3 runs, in seconds:
| Algorithm | Hash alone, from memory | Completion waits, segments in reverse (worst case) | Completion waits, segments in order |
|---|---|---|---|
| MD5 | 2.89 | 3.20 | 3.04 |
| SHA-1 | 0.91 | 1.18 | 1.16 |
| SHA-256 | 1.35 | 1.60 | 1.34 |
| SHA-512 | 3.30 | 3.40 | 3.52 |
What they show, and what they don't:
The hash itself is most of the cost. Reading back the whole file adds 0.1 to 0.3 s per GiB here, because the file was just written and the read comes from the operating system's cache. A read from the disk itself would take longer; it isn't measured.
In order isn't faster here, because the example writes from memory far faster than the hash runs, so the frontier is still behind when the last byte lands. A download slower than the hash, as from a network, keeps it nearly caught up; that isn't measured either.
SHA-1 and SHA-256 are faster than MD5 on this CPU, which has SHA instructions that RustCrypto uses.
Single runs vary: the slowest of the three was up to 1.4 times the median.
Run it with cargo run --release -p velocity-transfer --example hash_readback -- [dir] [--mib N] [--runs R] [--algorithms md5,sha1,sha256,sha512]. The numbers above were taken with --config profile.release.debug-assertions=true, which the examples needed until the HTTP/2 tests moved out of velocity-transfer (below); debug assertions don't touch the hashing code.
Disk writes far into a new file (Windows)
Segments are written in parallel, so a later segment writes far past what is already in the part file. Measured with crates/velocity-transfer/examples/high_offset_write.rs (report open question 7; #42, 2026-10-23). It writes 1 MiB into a new file and syncs it, 3 runs each:
| Machine | Offset of the write | No preallocation | After set_len | At offset 0 |
|---|---|---|---|---|
| Windows 11 (build 26200), NTFS, the system temp folder | 1 GiB | 0.6, 67 and 35 s | 4.5, 61 and 32 s | 0.4–0.5 ms |
| 4 GiB | 120, 158 and 92 s | 128, 122 and 82 s | 0.4–0.5 ms | |
| WSL2 Ubuntu 24.04, ext4 | 1 GiB and 4 GiB | 0.5–1.0 ms | 0.5 ms | 0.5 ms |
What happens: NTFS zero-fills the whole gap before the write returns, here at about 30 to 50 MB/s.
set_lendoesn't help, because it doesn't move NTFS's valid data length. ext4 leaves the gap empty.The effect, before #43: a multi-GB parallel download on Windows could stall for minutes on a later segment's first write. The occasional 10 to 18 s runs of the fixture tests on Windows were likely the same effect, on a 128 MiB file.
The fix (#43, PO decision on #42): the part file is sparse on Windows, so the gap is never filled.
After #43, on the same Windows machine (2026-10-23, --only sparse,at_zero, 3 runs each), the 1 MiB write into a part file as the engine creates it:
| Offset of the write | Sparse part file: write | Write and sync | At offset 0: write and sync |
|---|---|---|---|
| 1 GiB | 0.4–0.6 ms | 1.8–2.2 ms | 1.8–4.7 ms |
| 4 GiB | 0.5–0.7 ms | 2.0–7.2 ms | 1.7–2.1 ms |
| 16 GiB | 0.5–0.7 ms | 1.7–10.6 ms | 1.7–1.9 ms |
The gap is no longer filled: a write far into a sparse part file costs what a write at its start does. On a file system without sparse files (FAT, exFAT), the zero-fill remains.
Run it with cargo run --release -p velocity-transfer --example high_offset_write -- [dir] [--gib N] [--runs R] [--only no_prealloc,set_len,sparse,at_zero].
Tests
Fixtures:
crates/velocity-transfer/tests/download.rsruns the real engine against the local test server (velocity-test-http), whose options make it misbehave on demand. There is one test per row of report section 5.1's table, each checking the file's bytes, the number of GETs and the wasted bytes. A further test slows one connection so that a split happens, and checks that the old request stops at the boundary.Properties:
crates/velocity-transfer/tests/scheduler.rsruns 3,000 generated scenarios of a simulated transport through the scheduler, withproptest. The scenarios mix random requests, chunk sizes, splits, refused answers, duplicate and late callbacks, cancellations, syncs, crashes that keep only D, and new epochs. After every event it checks I1 to I7 and that every covered byte equals the source. At the end it checks that the finished file is the source. Breaking the scheduler on purpose is caught: a split that doesn't shrink the old lease, accepting bytes at any offset, and a failed lease that doesn't give its bytes back each fail it.Persistence fixtures:
tests/download.rsalso covers:checkpoints of synced bytes, the probe's included;
a pause and a resume from the last checkpoint, fetching only what was missing;
a file that changed on the server, which starts over;
checkpoints that don't fit, which aren't trusted;
a missing or discarded part file;
a file created at the destination mid-download, never replaced;
the sparse part file.
tests/engine_contract.rschecks pause, resume and cancel in both engine modes.Crash trials (L4):
tests/crash_recovery.rskills a real engine (kill -9,taskkill /F) at a random moment, then resumes in a new one from the last checkpoint the first sent. Every trial checks four things:nothing looks finished before the rename;
the file equals the source;
what wasn't durable at the kill is at most one checkpoint interval's worth;
the bytes fetched twice are at most that plus what was in flight.
The same file kills the app too (#74): the test binary starts itself again as an app process (the manager over a database file, with its own child engine), kills it at a random moment, waits for its orphaned engine to stop, then opens the same database in a new manager, which brings the download back from its stored checkpoint and must finish it with the source's bytes.
CI runs 3 trials of each. The full 100 run by hand with
VELOCITY_CRASH_TRIALS=100 cargo test -p velocity-transfer --test crash_recovery -- --nocapture(PO decision on #43), andVELOCITY_CRASH_SEEDrepeats a run.A full disk and a refused write (#74):
crates/velocity-transfer-h2c/tests/disk_faults.rs, through the manager and the in-process engine. velocity-transfer's test-onlytest-faultsfeature makes the part file's writes fail after a chosen number of bytes, as a full disk (StorageFull) or a refused write (PermissionDenied) would; an optimised build refuses the feature, liketest-ca. No real small drive is made (PO decision on #74). The fault strikes once inside the 4 MiB probe and once among the parallel segments, never on a chunk boundary, so the failing write is cut part-way, as an interrupted write is. Each case checks:the download fails, as permanent, with "There is not enough disk space." or "Apollo Download Manager isn't allowed to write to that location.";
the part file and a checkpoint of what reached the disk are kept;
once the fault is cleared, the user's retry fetches only what was missing, and the file equals the source.
Changed-object fixtures (#45):
tests/changed.rschecks every request'sRangeandIf-Rangeagainst a server whose file changes after a set number of GETs:a new strong ETag starts over once and ends as the new file;
a dropped ETag counts as a change;
a weak ETag is never sent, and a strong date is;
a second change fails as retryable, with no part file left;
a resume whose ETag changed starts over;
the gap stream after ranges were lost, and a retried probe, are checked too;
an unasked encoding is stored as sent, a changed one starts over as one stream, and a resume needs its checkpoint's encoding;
a
403after the first answer says the link expired.
Retry fixtures:
tests/retries.rscovers #48's acceptance fixtures:a
429withRetry-After: 5gets no request before 5 s;a reset storm backs off within its bounds and recovers, and one that doesn't end fails after 5 tries;
a 20 KiB/s server is never reset;
a frozen connection shows Stalled, then is reset and retried;
a name that doesn't resolve is transient;
a
404is permanent with its message;a cancel during a wait ends at once.
tests/engine_retries.rscovers starting a whole download again, keeping the bytes through an outage that fails a resumed start too, riding an outage out past the counted restarts (#317), giving up after the restarts, never restarting a permanent failure, a cancel during the wait, and Stalled in the snapshot.tests/supervisor.rsfreezes a real engine process and checks it is restarted.Limit fixtures:
tests/limits.rsmeasures the bytes written over a window: a global 4 MiB/s across 3 downloads, a change taking effect within 2 s, and a download limit under a higher global one, each within ±10% over 10 s.VELOCITY_LIMIT_SECONDS=30 cargo test -p velocity-transfer --test limits -- --nocaptureruns the 30 s window of #49 by hand (PO decision on #49).tests/engine_contract.rschecks limits in both engine modes, andtests/supervisor.rschecks that the global limit survives a restart.Connection fixtures (#47):
tests/connections.rschanges a download's count live (up and down, with no byte wasted and no second probe), holds it to the engine's allowance, reports when the file is too small for more, and runs the adaptive ramp against a per-connection and a per-IP throttle.tests/engine_contract.rschecksset_connections, the per-host share with Jain's index, and connection reuse in both engine modes. Since #154, so that slow machines don't fail them:tests/connections.rsruns its tests one at a time, and so do the three intests/engine_contract.rsthat measure speed. At once, on 2 vCPUs, they stretched the ramp's windows to almost 3 s. Since #281,velocity-transfer-h2c/tests/http2.rsruns its tests one at a time too: its probe and split tests download at loopback speed, and slowed the ramp's measurements on CI's Windows runner;the ramp fixtures use the engine's own policy, with its 2 s windows. With 1 s windows, noise passed for a 10% gain;
#154's known failure is gone with #221: about 1 run in 25 on 2 vCPUs, the per-IP fixture saw the climbing ramp try a 5th connection when noise passed for a gain. The ramp now starts at the count asked for and never adds beyond it, and noise can only keep a connection: the fixture asks for 4 and accepts 2 or 3 kept;
the ramp fixtures check "never more than asked" with the engine's own count,
DownloadReport::peak_requests. A server counts a stopped request for a moment longer, because it writes the request's tail until it sees the connection close;the fairness fixture measures once every download is downloading on its 2 connections. A download's allowance alone is set before it connects.
Integrity fixtures (#46):
tests/integrity.rsruns against the test server:a correct checksum completes a download whose segments land out of order, with Verifying shown;
a wrong one fails with the part file kept and nothing renamed, and a resume from its last checkpoint downloads every byte again;
checksums on a single stream and an empty file;
Verified, Size verified and Not verified, for fresh and resumed downloads with and without a strong ETag;
a piece the server sends bad once (
ServerOptions::corrupt_once) is fetched again alone;a piece that stays bad fails after 2 re-fetches;
without ranges, a bad piece starts the download over once;
a piece list that doesn't fit fails;
after a piece is fetched again, the whole-file hash still matches.
The unit tests in
integrity.rscheck the six algorithms against their standard test vectors, a frontier fed in reverse order, and the piece verifier.tests/engine_contract.rschecks checksums and piece hashes in both engine modes.HTTP/2 fixtures (#47):
crates/velocity-transfer-h2c/tests/http2.rsruns against the cleartext HTTP/2 server: #41's probe fixtures, #42's split that resets only its stream, and the multiplex policy against a server throttling each TCP connection and one throttling each stream. They need thetest-h2cfeature, which only test builds have (TESTING.md). They live in a test-only crate of their own (#46): a dev-dependency's features reach every example of its crate, so while they were invelocity-transfer, none of its examples could be built for release.TLS fixtures (#69):
crates/velocity-transfer-h2c/tests/tls.rsdownloads in segments over HTTPS and over HTTP/2 negotiated through ALPN, and checks that a refused certificate fails permanently with nothing on disk. It needs thetest-cafeature, so it lives in the same test-only crate. What each TLS backend accepts and refuses is in SECURITY.md.FTP fixtures (#51): the FTP server in
ftp.rsis V1's test server ported to the standard library. It serves passive FTP withSIZE,MDTMandREST, explicit and implicit FTPS on aTestPki, and switches for noSIZE, noREST, noMDTMand a cut transfer. It records the logins,RETRs,RESToffsets and which connections were encrypted.crates/velocity-curl/tests/ftp.rschecks the head built for each case in the table above, the login, a refused login, a cut, and FTPS: explicit, implicit, required from a plain server, and an untrusted certificate.crates/velocity-transfer/tests/ftp.rsports V1's tests: a whole file whose SHA-256 matches, a cut transfer resumed withREST, and a missing file. It adds a pause resumed from its checkpoint, a resume with and withoutMDTM, a file whose time changed, and a server withoutREST.tests/engine_contract.rsdownloads over FTP with a login in both engine modes, on one connection.crates/velocity-transfer-h2c/tests/ftps.rsdownloads over explicit and implicit FTPS on the test CA, and checks that requiring encryption from a plain server, and a refused certificate, fail at once without the login.
Unit tests: the tests in
probe.rs,validator.rs,failure.rs,retry.rs,adaptive.rs,budget.rs, the libcurl layer'srate.rs,scheduler.rs,files.rsand the libcurl layer'stests/curl_loop.rs(FetchLimit, the per-host cap) andtests/http2.rscover each rule on its own.
Not yet
Logins inside the engine: the engine takes the login as plain strings for the transfer's life, copied out of its
Secrets inrequest_options, because libcurl needs them so. Outside the engine they areSecrets, wiped when dropped (SECURITY.md).A local Metalink file:
metalink::read_fileexists, but no command calls it (Metalink).FTP over several connections: one connection until #77 measures whether more help.
Connection values: the per-host cap and the smallest piece come from the performance profile (#57), and the budget from the user's engine settings (SETTINGS.md, #30). The ramp's values are engine defaults, to tune by measurement (#77).
Learning per host: what a host allowed isn't remembered between downloads (a non-goal of #47).
Throttling across downloads: a
429or503halves the connections of the download that got it, not of the other downloads already running to that host. Downloads that start later from that host start at the remembered count (Opening).Retry settings: the user's engine settings over
RetryPolicy::DEFAULT(SETTINGS.md, #30), read when a download starts or retries.Integrity without the app: a whole-file hash that a server gives (
Repr-Digest) is deferred (report section 5.3).Metalink in the app: off by default, as in V1; the Settings screen's Advanced section turns it on (
advanced.enableMetalink, #30).Mirror health isn't remembered between downloads, or between the engine's restarts of one download, like the per-host learning #47 left out.
Connections to mirrors count against the first URL's host in the engine's budget (#47), so a download with mirrors never opens more connections than one without; they're only spread. Per-mirror budgets, and more connections across several hosts as aria2's
splitallowed, are #77's to measure.Mirror values: the cooldown, the failure limit and the scoring are starting values; #77 measures them.
Folders a Metalink download creates stay when it's cancelled or fails, even when empty. Only part files are removed.
A fuzz target for the Metalink parser comes with #73, which brings the fuzzing tool.
Proxy (#192)
The proxy the user set (SETTINGS.md, ADR-0008) reaches the engine through set_proxy (transfer contract version 16). The engine keeps it, and each transfer reads it as its worker starts, so a download that starts, resumes or retries uses the proxy of that moment. The supervisor gives it to a new engine before anything else.
libcurl (HTTP, HTTPS, FTP):
CURLOPT_PROXYis always set: the proxy, or empty, sohttp_proxy,ALL_PROXYand the like are never used. Through an HTTP or HTTPS proxy every connection is aCONNECTtunnel (CURLOPT_HTTPPROXYTUNNEL), even forhttp://and FTP, so the proxy never reads a request, its headers or a site's login. The proxy's own answer toCONNECTis kept from the header callback (CURLOPT_SUPPRESS_CONNECT_HEADERS), so the engine sees only the server's: before #344 a new tunnel's200 Connection established, with no ETag, was taken for a file that changed. A SOCKS5 proxy is used assocks5h: the proxy resolves the names. The proxy's login goes inCURLOPT_PROXYUSERNAMEandCURLOPT_PROXYPASSWORD, Basic only, to the proxy alone; an HTTPS proxy's certificate is verified as a server's.SFTP: russh has no proxy support, so velocity-sftp opens the connection itself (
proxy.rs): an HTTPCONNECTtunnel withProxy-Authorization: Basic, or a SOCKS5CONNECTby name with RFC 1929's login, and russh runs over it (connect_stream). The host key is checked against the server's name as without a proxy. An HTTPS proxy is refused: "SFTP can't go through an HTTPS proxy" (PO decision on #192).A refused proxy: an HTTP proxy's
407toCONNECT(read fromCURLINFO_HTTP_CONNECTCODE, also when no login was set), or a SOCKS5 refusal, fails the download as permanent: "The proxy refused the connection. Check its user name and password in Settings, Proxy."Local addresses (#67): through a proxy, the connection is to the proxy, which the user chose, so its address isn't held. A browser link that names this computer or a private network itself (
localhost, or a local IP address) still stops before its request; a name only the proxy resolves can't be checked (SECURITY.md).Tests: velocity-curl
tests/proxy.rsandtests/no_ambient_proxy.rs, velocity-sftptests/sftp.rs, against the local proxies invelocity_test_http::proxy(TESTING.md).