Skip to main content

rget/
worker.rs

1//! One worker transferring one range at a time.
2//!
3//! Memory discipline (PRD §30): a worker holds one body chunk at a time and
4//! writes it straight through to the file. Nothing accumulates, so peak memory
5//! is `connections × chunk`, independent of file size — a 500 GB download costs
6//! the same as a 5 GB one.
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use reqwest::Client;
13use tracing::{debug, warn};
14
15use crate::error::TransferError;
16use crate::file::DestFile;
17use crate::limit::RateLimiter;
18use crate::mirror::SourceSet;
19use crate::progress::{Event, Reporter};
20use crate::retry::{Decision, RetryPolicy};
21use crate::scheduler::{Lease, Scheduler};
22use crate::shutdown::Cancel;
23use crate::storage::OPEN_END;
24
25/// Everything a worker needs, shared by `Arc` across all of them.
26pub struct WorkerCtx {
27    pub client: Client,
28    pub sources: Arc<SourceSet>,
29    pub file: Arc<DestFile>,
30    pub scheduler: Arc<Scheduler>,
31    pub reporter: Reporter,
32    pub retry: RetryPolicy,
33    pub limiter: Option<Arc<RateLimiter>>,
34    /// Maximum silence between two body chunks before we call it a timeout.
35    pub read_timeout: Duration,
36    pub expected_total: Option<u64>,
37    /// When false we never send a `Range` header at all — asking a server that
38    /// ignores ranges for one guarantees a 200 with the whole body, which we
39    /// would (correctly) reject as a protocol violation.
40    pub ranges_supported: bool,
41    /// Filled in when an open-ended (unknown length) transfer discovers the
42    /// real size by reaching the end of the body.
43    pub discovered_size: Arc<AtomicU64>,
44    pub cancel: Cancel,
45    /// The response the priming probe left open, whose body starts at byte 0.
46    /// Whichever worker first picks up a lease sitting at byte 0 transfers it
47    /// instead of opening a redundant connection. Taken at most once.
48    pub primed: Mutex<Option<PrimedBody>>,
49}
50
51/// A live response body from [`crate::http::probe_priming`], tagged with the URL
52/// it came from so it can never be spliced into a different source's range.
53pub struct PrimedBody {
54    pub url: url::Url,
55    pub response: reqwest::Response,
56}
57
58impl WorkerCtx {
59    /// Claim the primed body, but only for a request that it actually answers:
60    /// the same URL, starting at the same byte.
61    fn take_primed(&self, url: &url::Url, start: u64) -> Option<reqwest::Response> {
62        if start != 0 {
63            return None;
64        }
65        let mut slot = self.primed.lock().unwrap_or_else(|e| e.into_inner());
66        match slot.as_ref() {
67            Some(primed) if &primed.url == url => slot.take().map(|p| p.response),
68            _ => None,
69        }
70    }
71}
72
73/// Outcome of a worker's whole run.
74pub enum WorkerOutcome {
75    /// No work left, or cancelled.
76    Finished,
77    /// A range failed permanently; the download cannot complete.
78    Fatal(TransferError),
79}
80
81/// Pull ranges until there are none left, the download is cancelled, or a
82/// non-recoverable error occurs.
83pub async fn run(ctx: Arc<WorkerCtx>, worker_id: usize) -> WorkerOutcome {
84    loop {
85        if ctx.cancel.is_cancelled() {
86            return WorkerOutcome::Finished;
87        }
88
89        let Some((lease, split)) = ctx.scheduler.acquire() else {
90            if ctx.scheduler.is_finished() || ctx.scheduler.has_failure() {
91                return WorkerOutcome::Finished;
92            }
93            // No pending range and nothing splittable: wait for another worker
94            // to finish or make enough progress to be worth stealing from.
95            tokio::select! {
96                _ = ctx.scheduler.wait_for_change(Duration::from_millis(250)) => {}
97                _ = ctx.cancel.cancelled() => return WorkerOutcome::Finished,
98            }
99            continue;
100        };
101
102        if let Some(split) = split {
103            ctx.reporter.emit(Event::RangeSplit {
104                index: split.shrunk.idx,
105                new_index: split.added.idx,
106                at: split.added.start,
107            });
108            debug!(
109                worker = worker_id,
110                victim = split.shrunk.idx,
111                new_range = split.added.idx,
112                at = split.added.start,
113                "split a slow range"
114            );
115        }
116
117        ctx.reporter.emit(Event::RangeStarted {
118            index: lease.idx,
119            start: lease.cursor(),
120            end: lease.end(),
121        });
122
123        match transfer(&ctx, &lease, worker_id).await {
124            Ok(()) => {
125                ctx.scheduler.complete(lease.idx);
126                ctx.reporter
127                    .emit(Event::RangeCompleted { index: lease.idx });
128                let (done, total) = ctx.scheduler.counts();
129                ctx.reporter.stats.set_ranges_complete(done);
130                ctx.reporter.stats.set_ranges_total(total);
131            }
132            Err(TransferError::Cancelled) => {
133                ctx.scheduler.release(lease.idx);
134                return WorkerOutcome::Finished;
135            }
136            Err(err) => {
137                // Retries are exhausted by the time we get here.
138                ctx.scheduler.fail(lease.idx);
139                warn!(worker = worker_id, range = lease.idx, %err, "range failed permanently");
140                return WorkerOutcome::Fatal(err);
141            }
142        }
143    }
144}
145
146/// Transfer one lease to completion, retrying transient failures in place so a
147/// blip costs us a reconnect rather than the range's progress (PRD §14).
148async fn transfer(ctx: &WorkerCtx, lease: &Lease, worker_id: usize) -> Result<(), TransferError> {
149    let mut attempts = 0u32;
150    loop {
151        if ctx.cancel.is_cancelled() {
152            return Err(TransferError::Cancelled);
153        }
154        if lease.remaining() == 0 {
155            return Ok(());
156        }
157
158        let (source_idx, source) = ctx.sources.pick();
159        let url = source.url.clone();
160        // The validator belongs to this source, not to the download.
161        let validator = source.validator.clone();
162        let before = lease.progress();
163        match attempt(ctx, lease, &url, validator.as_deref()).await {
164            Ok(()) => {
165                ctx.sources.reward(source_idx);
166                return Ok(());
167            }
168            Err(err) => {
169                // A connection that delivered bytes before dying is flaky, not
170                // broken. Charging it an attempt anyway would make us give up on
171                // a link that drops every few MiB but is otherwise progressing.
172                if lease.progress() > before {
173                    attempts = 0;
174                }
175                attempts += 1;
176                ctx.sources.penalise(source_idx);
177                match ctx.retry.decide(&err, attempts) {
178                    Decision::Retry { delay, attempt } => {
179                        ctx.reporter.stats.record_retry();
180                        ctx.reporter.emit(Event::RetryScheduled {
181                            index: Some(lease.idx),
182                            attempt,
183                            delay_ms: delay.as_millis() as u64,
184                            reason: err.to_string(),
185                        });
186                        debug!(
187                            worker = worker_id,
188                            range = lease.idx,
189                            attempt,
190                            ?delay,
191                            %err,
192                            "retrying range"
193                        );
194                        tokio::select! {
195                            _ = tokio::time::sleep(delay) => {}
196                            _ = ctx.cancel.cancelled() => return Err(TransferError::Cancelled),
197                        }
198                    }
199                    Decision::GiveUp => return Err(err),
200                }
201            }
202        }
203    }
204}
205
206/// One HTTP request for the outstanding part of a lease.
207async fn attempt(
208    ctx: &WorkerCtx,
209    lease: &Lease,
210    url: &url::Url,
211    validator: Option<&str>,
212) -> Result<(), TransferError> {
213    let start_cursor = lease.cursor();
214    let open_ended = lease.is_open_ended();
215
216    let (req_start, req_end) = if ctx.ranges_supported {
217        (
218            start_cursor,
219            if open_ended { None } else { Some(lease.end()) },
220        )
221    } else {
222        if start_cursor != 0 {
223            return Err(TransferError::Protocol(
224                "cannot resume from the middle: this server does not support range requests".into(),
225            ));
226        }
227        (0, None)
228    };
229
230    // The priming probe already opened a body starting at byte 0. Using it here
231    // is what makes a fresh download cost exactly as many requests as wget's.
232    let mut resp = match ctx.take_primed(url, req_start) {
233        Some(primed) => primed,
234        None => {
235            crate::http::get_range(
236                &ctx.client,
237                url,
238                req_start,
239                req_end,
240                validator,
241                ctx.expected_total,
242            )
243            .await?
244        }
245    };
246
247    ctx.reporter.stats.connection_opened();
248    // Guard so every early return below decrements the gauge exactly once.
249    let _conn = ConnectionGuard(&ctx.reporter);
250
251    let mut cursor = start_cursor;
252    let mut pending_event_bytes = 0u64;
253    let mut last_event = Instant::now();
254
255    loop {
256        if ctx.cancel.is_cancelled() {
257            return Err(TransferError::Cancelled);
258        }
259
260        // Cancellation is checked *inside* the read wait, not just around it:
261        // a stalled socket must not hold Ctrl+C hostage for a whole timeout.
262        let chunk = tokio::select! {
263            biased;
264            _ = ctx.cancel.cancelled() => return Err(TransferError::Cancelled),
265            read = tokio::time::timeout(ctx.read_timeout, resp.chunk()) => match read {
266                Err(_) => return Err(TransferError::Timeout(ctx.read_timeout)),
267                Ok(Err(e)) => return Err(TransferError::from_reqwest(&e)),
268                Ok(Ok(None)) => break,
269                Ok(Ok(Some(chunk))) => chunk,
270            },
271        };
272        if chunk.is_empty() {
273            continue;
274        }
275
276        // Re-read the ceiling every chunk: the scheduler may have handed our
277        // tail to an idle worker while this chunk was in flight.
278        let end = lease.end();
279        let writable = if open_ended && end >= OPEN_END {
280            chunk.len() as u64
281        } else {
282            let room = end.saturating_sub(cursor).saturating_add(1);
283            (chunk.len() as u64).min(room)
284        };
285        if writable == 0 {
286            // Our range shrank to nothing; the rest belongs to someone else.
287            break;
288        }
289
290        if let Some(limiter) = &ctx.limiter {
291            limiter.acquire(writable).await;
292        }
293
294        let slice = &chunk[..writable as usize];
295        ctx.file
296            .write_at(slice, cursor)
297            .map_err(|e| TransferError::Io(e.to_string()))?;
298
299        cursor += writable;
300        // Publishing progress is what makes the bytes eligible for the next
301        // durability barrier. It never claims durability by itself.
302        lease.publish_progress(cursor - lease.start);
303        ctx.reporter.stats.add_downloaded(writable);
304
305        pending_event_bytes += writable;
306        if last_event.elapsed() >= Duration::from_millis(100) {
307            ctx.reporter.emit(Event::BytesWritten {
308                index: lease.idx,
309                bytes: pending_event_bytes,
310            });
311            pending_event_bytes = 0;
312            last_event = Instant::now();
313        }
314
315        if !open_ended && cursor > end {
316            break;
317        }
318    }
319
320    if pending_event_bytes > 0 {
321        ctx.reporter.emit(Event::BytesWritten {
322            index: lease.idx,
323            bytes: pending_event_bytes,
324        });
325    }
326
327    if open_ended && lease.end() >= OPEN_END {
328        // The body ended, so now we know how big the resource actually was.
329        ctx.discovered_size.store(cursor, Ordering::Release);
330        ctx.scheduler
331            .set_end(lease.idx, cursor.saturating_sub(1).max(lease.start));
332        return Ok(());
333    }
334
335    if cursor > lease.end() {
336        return Ok(());
337    }
338
339    // The body ended before the range did. Almost always a dropped connection
340    // mid-transfer; retryable, and we keep every byte we did get.
341    Err(TransferError::Network(format!(
342        "connection closed with {} bytes of the range still missing",
343        lease.end() + 1 - cursor
344    )))
345}
346
347/// Keeps the active-connection gauge honest across every exit path.
348struct ConnectionGuard<'a>(&'a Reporter);
349
350impl Drop for ConnectionGuard<'_> {
351    fn drop(&mut self) {
352        self.0.stats.connection_closed();
353    }
354}