Skip to main content

hya_core/
sched.rs

1//! The scheduler kernel: pure state machine, no I/O, no clock, no allocation
2//! in the steady state.
3//!
4//! The caller drives it: feed observations (`on_bytes`, `on_complete`), call
5//! `tick(now)`, and act on the returned `Action`s. This is what lets the same
6//! code run under the discrete-event simulator and under real HTTP.
7//!
8//! Implements dynamic range partitioning, divergence-triggered steal-to-equalize,
9//! work-conserving assignment, queue dispatch, stall reclamation, and greedy concurrency.
10
11use crate::intervals::{IntervalSet, Range};
12
13/// Minimum steal quantum. A range rebalance smaller than this is not worth request overhead.
14pub const STEAL_QUANTUM: u64 = 64 * 1024;
15
16/// Bounded repairs per tick, so a tick is O(R * n).
17const MAX_REPAIRS_PER_TICK: usize = 4;
18
19/// EWMA weight on the newest goodput sample.
20const RATE_ALPHA: f64 = 0.3;
21/// Rate samples a connection must have produced before repair may move work
22/// on to it or off it.
23///
24/// Each sample is one `RATE_WINDOW` of arrivals, so this is the first
25/// `REPAIR_WARM_SAMPLES * RATE_WINDOW` seconds of delivery on a request — long
26/// enough that the estimate has left slow start behind, short enough that a
27/// connection genuinely slower than its peers is noticed within the first
28/// second. See `pick_victim_taker`.
29const REPAIR_WARM_SAMPLES: u32 = 3;
30/// How long a divergence between two BUSY connections must persist before it is
31/// acted on.
32///
33/// The rate estimate is a smoothed window, and smoothing lags: two connections
34/// on one fair path show apparent divergences of 15-20% for a window or two
35/// while their estimates catch up with each other, and the repair test cannot
36/// tell that from a connection that is genuinely slower. Traced on a uniform
37/// four-connection transfer with perfectly fair TCP (four plain `curl` ranges
38/// finish within 10 ms of each other): the good runs stole 9 and 17 MB slivers
39/// at t=0.8 s and t=1.8 s on exactly such a blip, and each sliver became an
40/// orphan that cost a fresh round trip in the tail; the bad runs cascaded into
41/// twenty shrinks, each one changing the ETAs that justified the next. Lag
42/// clears within a few windows. Slowness does not. Waiting this long separates
43/// them, and a repair that is worth making in second three is still worth
44/// making in second four.
45///
46/// Not applied to a victim the detector has graded as collapsing, nor to an
47/// idle taker: the first is measured evidence, the second is free capacity.
48const REPAIR_PERSIST: f64 = 3.0 * RATE_WINDOW;
49/// The same wait expressed in setup costs, whichever is longer. Six is a few
50/// round trips past the point where two flows that started together have
51/// left slow start; see the repair loop in `tick`.
52const REPAIR_PERSIST_DELTAS: f64 = 6.0;
53
54/// Minimum wall clock a rate sample must span, in seconds.
55///
56/// Below this the quotient is dominated by socket buffering rather than by the
57/// link: consecutive `read()` calls draining one already-arrived TCP window return
58/// in microseconds and imply a rate the network never achieved. 200 ms is long
59/// enough to average over several windows and short enough that a genuine collapse
60/// is still graded within the stall timeout.
61const RATE_WINDOW: f64 = 0.2;
62
63#[derive(Clone, Copy, PartialEq, Eq, Debug)]
64pub enum Action {
65    /// Issue `GET` with `Range: bytes=lo-(hi-1)` on this connection.
66    Request { conn: usize, range: Range },
67    /// Stop reading this connection's current response; its range was reclaimed.
68    Cancel { conn: usize },
69    /// The far end of this connection's in-flight range moved DOWN to `hi`: a
70    /// repair handed the tail `[hi, old_hi)` to another connection. Stop reading
71    /// at `hi`.
72    ///
73    /// # Why this action has to exist
74    ///
75    /// The whole claim of this scheduler is that shrinking a laggard's range is
76    /// free, because an HTTP range request names both ends and the far end is
77    /// enforced by the client. That is true of the protocol. It was NOT true of
78    /// this implementation: the repair below moved `conns[vi].range` and emitted
79    /// nothing, while the transport's fetch loop runs `while off < hi` against
80    /// the `hi` it captured when the request was spawned. The victim therefore
81    /// kept pulling the bytes it had just been relieved of, at the same time as
82    /// the taker pulled them, over the same bottleneck.
83    ///
84    /// So each repair cost roughly one stolen span of duplicated traffic instead
85    /// of nothing, and since the duplicate traffic slowed the honest
86    /// connections, it manufactured the very divergence that triggers a repair.
87    /// That positive feedback loop is the measured "repair storm": at n=8 on a
88    /// stationary 5.3 MB transfer, 32-49 repairs where the correct count is 0,
89    /// with in-run throughput decaying 439 -> 306 KiB/s.
90    ///
91    /// A caller that ignores this action is not merely leaving an optimisation
92    /// on the table; it reintroduces the storm.
93    Shrink { conn: usize, hi: u64 },
94}
95
96#[derive(Clone, Copy, PartialEq, Eq, Debug)]
97pub enum Capability {
98    /// Ranges honoured, length known, strong validator: full scheduling.
99    Full,
100    /// Ranges honoured but no validator: partition, but pin to one source.
101    NoValidator,
102    /// Ranges ignored or unsupported: race whole-object fetches.
103    Race,
104    /// Length unknown: single stream per source, no range arithmetic.
105    Stream,
106}
107
108/// Preference value meaning "the publisher stated no preference".
109///
110/// The scale is RFC 5854's: 1 is best, larger is worse. See [`Source::priority`].
111pub const NO_PRIORITY: u32 = 999_999;
112
113#[derive(Clone, Debug)]
114pub struct Source {
115    pub caps: Capability,
116    /// Per-connection goodput ceiling estimate, bytes/s.
117    pub gamma_est: f64,
118    /// Per-source shaping cap estimate, bytes/s.
119    pub rho_est: f64,
120    /// Measured request setup cost, seconds.
121    pub delta_est: f64,
122    /// Suspended until this time (429/503 Retry-After, or stall backoff).
123    pub suspended_until: f64,
124    /// Consecutive stalls observed on this source; drives exponential backoff.
125    pub consecutive_stalls: u32,
126    /// Publisher-stated preference: 1 is best, [`NO_PRIORITY`] means unranked.
127    ///
128    /// # Why this is a PRIOR and nothing more
129    ///
130    /// Everything else in this struct is measured. This is not: it is what a
131    /// mirror list (Metalink `priority`/`preference`, an RFC 6249 `Link ... pri=`)
132    /// says about a source before a single byte has moved. It is worth having
133    /// because the first split has to be made from *something*, and a
134    /// publisher's ranking is a better guess than an arbitrary order — it
135    /// usually encodes geography, bandwidth, and whether the host is a CDN or a
136    /// volunteer.
137    ///
138    /// It is worth having ONLY as a prior. A ranking cannot know that the
139    /// preferred mirror is currently overloaded, and this scheduler exists
140    /// precisely because that is discoverable at runtime and correctable for
141    /// free. So `priority` biases [`Scheduler::initial_split`] and nothing else:
142    /// once `gamma_est` and the per-connection rate estimates have real samples
143    /// in them, repair decisions are made on measurement, and a highly-ranked
144    /// mirror that is slow loses its work exactly as fast as an unranked one
145    /// would. Letting the prior persist would be strictly worse than having no
146    /// ranking at all, because it would defend the wrong source against
147    /// evidence.
148    pub priority: u32,
149    /// False once a source has been retired: it is out of the transfer for good.
150    ///
151    /// Distinct from `suspended_until`, which is a pause with an end. A retired
152    /// source is one a caller has replaced or abandoned — an unreachable host, a
153    /// mirror serving the wrong object — and it must never be handed work again,
154    /// however long the transfer runs. Conflating the two is how a dead mirror
155    /// gets retried every backoff for the life of the download.
156    pub live: bool,
157}
158
159impl Default for Source {
160    fn default() -> Self {
161        Source {
162            caps: Capability::Full,
163            gamma_est: 0.0,
164            rho_est: f64::INFINITY,
165            delta_est: 0.05,
166            suspended_until: 0.0,
167            consecutive_stalls: 0,
168            priority: NO_PRIORITY,
169            live: true,
170        }
171    }
172}
173
174impl Source {
175    /// Relative share of the first split this source's ranking argues for.
176    ///
177    /// `1 / priority`, so rank 1 gets twice rank 2 and eight times rank 8, and
178    /// every unranked source gets the same small share as every other. The exact
179    /// curve is a judgement call and is deliberately gentle: it is a prior over a
180    /// quantity nobody has measured yet, and the correction costs one repair.
181    /// A steeper curve would concentrate the object on one mirror and turn the
182    /// publisher's guess into a single point of failure — the opposite of what a
183    /// mirror list is for.
184    pub fn priority_weight(&self) -> f64 {
185        1.0 / (self.priority.max(1) as f64)
186    }
187
188    /// May this source be given work right now?
189    ///
190    /// Retirement and suspension are separate conditions with one answer, and
191    /// every assignment path must ask the combined question. A path that checks
192    /// only `suspended_until` hands work to a retired mirror the moment its last
193    /// backoff expires — which is exactly the host a caller retired it for.
194    #[inline]
195    pub fn usable_at(&self, now: f64) -> bool {
196        self.live && now >= self.suspended_until
197    }
198}
199
200#[derive(Clone, Debug)]
201struct Conn {
202    source: usize,
203    /// Active range and how far into it we are.
204    range: Option<Range>,
205    pos: u64,
206    /// One-slot pipeline: a range handed over by a repair.
207    queued: Option<Range>,
208    rate_est: f64,
209    /// Changepoint detector. `rate_est` remains the smoothed rate used for ETA
210    /// projection; this grades the connection so repair can pre-empt a collapse
211    /// instead of waiting for the stall timeout (see `detect.rs`).
212    detector: crate::detect::CollapseDetector,
213    last_progress: f64,
214    setup_end: f64,
215    /// When the request this connection is running now was issued.
216    ///
217    /// Arrivals older than this belong to a request that has been superseded —
218    /// reclaimed after a stall, cancelled, or failed — and must not be credited,
219    /// even when they land exactly at the cursor. See `on_bytes_at`.
220    started_at: f64,
221    stalled: bool,
222    /// Bytes and wall clock accumulated since the last RATE sample.
223    ///
224    /// Rate is measured over a fixed WINDOW, not per arrival. An arrival is one
225    /// `read()` return, and a read served from the socket's already-buffered data
226    /// completes in microseconds, so `bytes/dt` for that arrival measures memcpy
227    /// speed rather than network speed — observed as 128 MiB/s on a connection
228    /// whose link was doing well under 1 MiB/s.
229    ///
230    /// That is not merely a cosmetic display bug. Those inflated samples raise the
231    /// detector's reference level, after which every honest sample looks like a
232    /// collapse against it, and the CUSUM grades a perfectly healthy connection
233    /// `Degraded` — which is why all eight connections of a working transfer
234    /// showed as `bad`. Byte accounting stays exactly per-arrival (coverage must
235    /// be exact); only the rate estimate is windowed.
236    rate_acc_bytes: u64,
237    rate_acc_dt: f64,
238}
239
240impl Conn {
241    fn new(source: usize) -> Self {
242        Conn {
243            source,
244            range: None,
245            pos: 0,
246            queued: None,
247            rate_est: 0.0,
248            detector: crate::detect::CollapseDetector::new(),
249            rate_acc_bytes: 0,
250            rate_acc_dt: 0.0,
251            last_progress: 0.0,
252            setup_end: 0.0,
253            started_at: f64::NEG_INFINITY,
254            stalled: false,
255        }
256    }
257
258    #[inline]
259    fn busy(&self) -> bool {
260        self.range.map(|r| self.pos < r.hi).unwrap_or(false)
261    }
262
263    /// Bytes still owed on the active range plus anything pipelined.
264    #[inline]
265    fn outstanding(&self) -> u64 {
266        let active = self
267            .range
268            .map(|r| r.hi.saturating_sub(self.pos))
269            .unwrap_or(0);
270        active + self.queued.map(|r| r.len()).unwrap_or(0)
271    }
272
273    /// Projected seconds to drain. A stalled or unmeasured connection projects
274    /// to infinity so it is always chosen as the repair victim.
275    fn eta(&self) -> f64 {
276        let out = self.outstanding();
277        if out == 0 {
278            return 0.0;
279        }
280        if self.rate_est <= 0.0 {
281            return f64::INFINITY;
282        }
283        out as f64 / self.rate_est
284    }
285}
286
287#[derive(Clone, Copy, Debug, Default)]
288pub struct Stats {
289    pub requests: u64,
290    pub repairs: u64,
291    pub reclaims: u64,
292    pub bytes_held: u64,
293}
294
295/// Why the active connection count sits below the budget.
296///
297/// The scheduler does not act on this. It is carried here because every front
298/// end observes a transfer through a callback that receives `&Scheduler` and
299/// nothing else, and a dormant connection row that cannot say WHY it is dormant
300/// reads as a dropped connection. That is how a transfer behaving correctly —
301/// the in-band search measured two connections as slower than one and settled at
302/// one, matching `curl` on the same object — came to be filed as a bug: the rows
303/// said "waiting (connection limit)" and nothing said a measurement had been
304/// taken or what it concluded.
305#[derive(Clone, Copy, Debug, Default, PartialEq)]
306pub enum LimitReason {
307    /// Nothing has lowered the count; every connection may be admitted.
308    #[default]
309    None,
310    /// The in-band search is still measuring whether more connections pay.
311    Measuring,
312    /// The search finished: `tried` connections delivered `tried_rate` bytes/s
313    /// against `chosen` at `chosen_rate`, and the smaller count won. Rates are
314    /// zero when that level was never measured directly.
315    Measured {
316        chosen: usize,
317        chosen_rate: f64,
318        tried: usize,
319        tried_rate: f64,
320    },
321    /// The origin refused requests (`429`/`503`) beyond `serving` at once.
322    Refused { serving: usize },
323    /// The origin accepted connections beyond `serving` and then served them
324    /// nothing for a whole stall timeout while the others streamed.
325    Starved { serving: usize },
326}
327
328pub struct Scheduler {
329    size: u64,
330    unassigned: IntervalSet,
331    held: u64,
332    conns: Vec<Conn>,
333    sources: Vec<Source>,
334    /// Repair deadband scale; theta = scale * sqrt(delta * T_rem / n).
335    /// Reused index buffer for the per-tick stalled-connection scan.
336    ///
337    /// The scan runs 50 times a second at the default tick and allocated a fresh `Vec`
338    /// each time, to hold at most `n_conns` indices. Reusing one buffer costs a field
339    /// and removes the allocation from the hot loop.
340    scratch_idx: Vec<usize>,
341    /// Reused index buffer for the per-tick assignment visit order.
342    ///
343    /// Separate from `scratch_idx` because the stalled scan above is still
344    /// holding that one when this is built, and for the same reason it exists:
345    /// this runs every tick, and a fresh `Vec` per tick is the allocation the
346    /// module header promises not to make.
347    scratch_order: Vec<usize>,
348    theta_scale: f64,
349    stall_timeout: f64,
350    /// How many connections may hold work at once. Adjustable mid-transfer so the
351    /// concurrency search can run on the real transfer rather than on probe
352    /// traffic; see `set_active_limit`.
353    active_limit: usize,
354    /// The largest `active_limit` this transfer can still reach.
355    ///
356    /// Distinct from `conns.len()`, which is the connection BUDGET, because the two
357    /// stopped meaning the same thing once the transport learned to lower its own
358    /// concurrency: an origin answering `429` teaches the transfer a ceiling well
359    /// below the budget, and nothing above that ceiling will ever be admitted.
360    ///
361    /// Assignment reads it. While concurrency can still grow, an idle connection is
362    /// handed a share of the remaining work rather than all of it, so the
363    /// connections admitted later find work waiting instead of having to steal.
364    /// Sizing that share against the budget when the ceiling is a fraction of it
365    /// hands out shares a fraction of the right size, and the transfer pays a
366    /// request — a round trip, and on a refused origin a fresh handshake — for
367    /// every one of them. Measured on a hermetic origin that serves two connections
368    /// and refuses the rest: 31 requests to deliver a 16 MB object at `-x 8`
369    /// against 2 at `-x 2`, and the difference was almost entirely first-byte
370    /// latency.
371    conn_ceiling: usize,
372    /// Why `active_limit` is where it is, for the UI. See [`LimitReason`].
373    limit_reason: LimitReason,
374    /// `HYDRA_TRACE_REPAIR` was set at construction: print every repair decision.
375    trace_repair: bool,
376    /// The connection the divergence test has been naming as victim, and since
377    /// when. A repair between two busy connections waits for the same victim to
378    /// stay divergent for `REPAIR_PERSIST`; see the repair loop in `tick`.
379    repair_candidate: Option<(usize, f64)>,
380    /// When false, victim selection ignores detector health and ranks purely by
381    /// projected ETA (the pre-detector behaviour). Exists so the detector's
382    /// contribution can be A/B measured rather than assumed.
383    health_ranking: bool,
384    started: bool,
385    pub stats: Stats,
386}
387
388impl Scheduler {
389    pub fn new(size: u64, sources: Vec<Source>, conns_per_source: &[usize]) -> Self {
390        let mut conns = Vec::new();
391        for (i, &k) in conns_per_source.iter().enumerate() {
392            for _ in 0..k {
393                conns.push(Conn::new(i));
394            }
395        }
396        Scheduler {
397            size,
398            unassigned: IntervalSet::full(size),
399            held: 0,
400            conns,
401            sources,
402            scratch_idx: Vec::new(),
403            scratch_order: Vec::new(),
404            theta_scale: 1.0,
405            stall_timeout: 1.0,
406            health_ranking: true,
407            // Default: every connection active, so nothing changes for callers that
408            // do not opt into the ramp.
409            active_limit: usize::MAX,
410            conn_ceiling: usize::MAX,
411            limit_reason: LimitReason::None,
412            trace_repair: std::env::var_os("HYDRA_TRACE_REPAIR").is_some(),
413            repair_candidate: None,
414            started: false,
415            stats: Stats::default(),
416        }
417    }
418
419    /// Cap how many connections may hold work at once, adjustable mid-transfer.
420    ///
421    /// # Why the concurrency search belongs here and not in a probe
422    ///
423    /// Finding the useful connection count by *probing* — fetch a slab with one
424    /// connection, then with two, then three, comparing goodput — is the standard
425    /// approach and it is what this client did. HARP (Kim, Yildirim, Kosar, SC'16)
426    /// names the cost directly: probing "may bring too much probing overhead",
427    /// because the samples are extra transfers whose price is paid before the real
428    /// one starts. Measured here on a 3.15 MB object over a live path, the climbing
429    /// probe made the transfer **1.96x slower** than not probing at all
430    /// (paired over 9 interleaved reps, p = 0.004) — the search cost more than the
431    /// concurrency it found could save.
432    ///
433    /// The probe is only necessary because concurrency is fixed when the transfer
434    /// starts. Make it adjustable and the same search runs on the *real* transfer:
435    /// start at one connection, measure aggregate goodput over a short window,
436    /// admit another connection while the marginal gain justifies it, and stop.
437    /// Every byte moved during the search is a byte of the object, so the search
438    /// is free — the object had to be fetched anyway. What HARP buys with a
439    /// historical corpus, this buys by putting the measurement in-band.
440    ///
441    /// Connections above the limit stay dormant: they are not given work and open
442    /// no socket. Raising the limit lets the next tick hand them work through the
443    /// ordinary work-conserving path, so no new admission machinery is needed.
444    pub fn set_active_limit(&mut self, n: usize) {
445        self.active_limit = n.clamp(1, self.conns.len().max(1));
446    }
447
448    /// The current concurrency cap.
449    pub fn active_limit(&self) -> usize {
450        self.active_limit
451    }
452
453    /// Record why the cap is where it is. The transport owns the reasons: it is
454    /// the one that sees refusals, starvation and the search's verdict.
455    pub fn set_limit_reason(&mut self, why: LimitReason) {
456        self.limit_reason = why;
457    }
458
459    /// Why `active_limit` sits below the budget, for a front end to explain a
460    /// dormant connection. Meaningful only while `active_limit() < n_conns()`.
461    pub fn limit_reason(&self) -> LimitReason {
462        self.limit_reason
463    }
464
465    /// Would this tick reclaim connection `j` for silence?
466    ///
467    /// The exact predicate `tick` applies, exposed so the transport can act on a
468    /// stall in the SAME tick that reclaims it rather than one round later. A
469    /// connection that was requested and has delivered nothing by the time this
470    /// fires — while others on the same origin stream — is an origin that admits
471    /// more connections than it serves. Lowering the concurrency cap after `tick`
472    /// has already re-requested the reclaimed range hands it straight back to a
473    /// connection the origin is going to starve again, and the transfer pays a
474    /// whole stall timeout to learn nothing.
475    pub fn conn_stalling(&self, j: usize, now: f64) -> bool {
476        self.conns
477            .get(j)
478            .map(|c| {
479                c.busy()
480                    && now >= c.setup_end
481                    && (now - c.last_progress.max(c.setup_end)) > self.stall_timeout
482            })
483            .unwrap_or(false)
484    }
485
486    /// Declare the largest concurrency this transfer can still reach.
487    ///
488    /// Lowered when the origin refuses requests, raised when a refusal-free stretch
489    /// earns a connection back. Assignment reserves work only for connections that
490    /// can actually arrive, so telling the scheduler the real ceiling is what stops
491    /// a throttled transfer from carving the object into budget-sized shares nobody
492    /// will ever come for.
493    pub fn set_conn_ceiling(&mut self, n: usize) {
494        self.conn_ceiling = n.clamp(1, self.conns.len().max(1));
495    }
496
497    /// The largest concurrency still reachable, never above the budget.
498    fn ceiling(&self) -> usize {
499        self.conn_ceiling.min(self.conns.len()).max(1)
500    }
501
502    /// When every source is deliberately suspended, the earliest time one returns.
503    ///
504    /// `None` means at least one source is usable now, so a lack of progress is a
505    /// genuine stall. `Some(t)` means the scheduler has *chosen* to pause every
506    /// source until `t` — nothing can move before then, and that silence is planned
507    /// rather than pathological.
508    ///
509    /// # Why a caller must consult this
510    ///
511    /// The transport's no-progress watchdog exists to fail a transfer where nothing
512    /// will ever happen again. A scheduled retry is the opposite of that, and
513    /// conflating the two is not hypothetical: with one source (the common case —
514    /// one URL, one CDN), `stall_timeout` 4.0s gives a watchdog of
515    /// `4 * (4.0 + delta)` = 16.2s, while five consecutive stalls suspend that sole
516    /// source for `min(4.0 * 2^3, 30)` = 30s. The transfer is then killed at 16.2s
517    /// for failing to make progress it had itself forbidden.
518    ///
519    /// Measured consequence on a 121.7 MiB GitHub release asset: 4 of 8 runs at
520    /// `-x 8`/`-x 16` aborted with a digest mismatch, three of them having already
521    /// received 126.9-127.0 MB of 127.6 MB — 99.6% complete, killed during a
522    /// deliberate backoff over the last half-megabyte.
523    pub fn all_sources_suspended_until(&self, now: f64) -> Option<f64> {
524        let mut earliest = f64::INFINITY;
525        for s in &self.sources {
526            // A retired source is not "coming back at time t" — it is gone. Its
527            // (never-updated) `suspended_until` must not be reported as a time
528            // the transfer should wait for, or a caller that retires a dead
529            // mirror hears "everything resumes at 0.0" and treats real silence
530            // as planned.
531            if !s.live {
532                continue;
533            }
534            if s.suspended_until <= now {
535                return None;
536            }
537            earliest = earliest.min(s.suspended_until);
538        }
539        if earliest.is_finite() {
540            Some(earliest)
541        } else {
542            None
543        }
544    }
545
546    /// Whether any work is still unclaimed by any connection.
547    ///
548    /// Exposed so the ramp's contract is testable: while concurrency is below the
549    /// budget, work must remain here for connections admitted later to pick up.
550    pub fn unassigned_is_empty(&self) -> bool {
551        self.unassigned.is_empty()
552    }
553
554    /// How many bytes are still unclaimed by any connection.
555    ///
556    /// The same contract as [`Self::unassigned_is_empty`], measured rather than
557    /// merely asserted: a reserve that has been whittled down to one sliver is
558    /// not empty and is not a reserve either.
559    pub fn unassigned_total(&self) -> u64 {
560        self.unassigned.total()
561    }
562
563    /// How many connections currently hold a range.
564    pub fn busy_conns(&self) -> usize {
565        self.conns.iter().filter(|c| c.busy()).count()
566    }
567
568    /// Connections that count against `active_limit` right now: busy, or already
569    /// holding queued work one tick from starting.
570    ///
571    /// This is what "dormant" is measured against, not connection index. The
572    /// budget is a COUNT of connections in play, not a privilege attached to
573    /// low indices — a connection above `active_limit` that is still busy is
574    /// not "excess", it is simply already spending the budget it was granted
575    /// when it was admitted, and one at any index is free to spend it once
576    /// something else stops.
577    ///
578    /// Queued connections are counted for the same reason `on_bytes` and
579    /// divergence repair must not both admit into the same headroom in one
580    /// tick: a connection with `queued` set has already been promised a slot,
581    /// even though it has not opened a socket yet.
582    fn admitted(&self) -> usize {
583        self.conns
584            .iter()
585            .filter(|c| c.busy() || c.queued.is_some())
586            .count()
587    }
588
589    /// Start with only `n` connections active, ramping up from there.
590    pub fn with_active_limit(mut self, n: usize) -> Self {
591        self.set_active_limit(n);
592        self
593    }
594
595    pub fn with_theta_scale(mut self, s: f64) -> Self {
596        self.theta_scale = s;
597        self
598    }
599
600    /// Disable health-ranked victim selection (for A/B measurement only).
601    pub fn with_health_ranking(mut self, on: bool) -> Self {
602        self.health_ranking = on;
603        self
604    }
605
606    pub fn with_stall_timeout(mut self, t: f64) -> Self {
607        self.stall_timeout = t;
608        self
609    }
610
611    /// Mark `[lo, hi)` as already held, for resuming a partial transfer.
612    ///
613    /// Must be called before the first `tick`: the initial split assigns all
614    /// unassigned work, and bytes already on disk must not be part of it.
615    pub fn mark_done(&mut self, lo: u64, hi: u64) {
616        let (lo, hi) = (lo.min(self.size), hi.min(self.size));
617        if hi <= lo {
618            return;
619        }
620        // Credit only the bytes this call actually claims, measured as the drop in
621        // the unassigned set — NOT the width of the span asked for.
622        //
623        // Callers legitimately overlap. A `-c` resume marks the sidecar's ranges
624        // held, and the concurrency probe separately reports the bytes it fetched;
625        // both start at offset 0, so the same prefix is marked twice. Crediting
626        // `hi - lo` each time made `held` exceed the bytes that exist, and `held`
627        // is what `is_complete()` tests: the transfer stopped early believing it
628        // was finished, leaving a zero-filled hole in the tail of a file reported
629        // as a success. Measured on an interrupted-then-resumed 11 200 900-byte
630        // object: 240 138 bytes of tail never written, `ok: true`, and the gzip
631        // refused to decompress.
632        let before = self.unassigned.total();
633        self.unassigned.remove(lo, hi);
634        let claimed = before.saturating_sub(self.unassigned.total());
635        self.held = self.held.saturating_add(claimed);
636    }
637
638    /// Health grade of a connection, for the progress UI and for tests.
639    pub fn conn_health(&self, j: usize) -> crate::detect::Health {
640        self.conns
641            .get(j)
642            .map(|c| c.detector.health())
643            .unwrap_or_default()
644    }
645
646    /// Source index a connection belongs to, for the progress UI.
647    pub fn conn_source(&self, j: usize) -> usize {
648        self.conns.get(j).map(|c| c.source).unwrap_or(0)
649    }
650
651    /// Smoothed rate estimate of a connection (bytes/s), for the progress UI.
652    pub fn conn_rate(&self, j: usize) -> f64 {
653        self.conns.get(j).map(|c| c.rate_est).unwrap_or(0.0)
654    }
655
656    /// Active range of a connection, for the progress UI.
657    pub fn conn_range(&self, j: usize) -> Option<(u64, u64, u64)> {
658        self.conns
659            .get(j)
660            .and_then(|c| c.range.map(|r| (r.lo, c.pos, r.hi)))
661    }
662
663    pub fn n_conns(&self) -> usize {
664        self.conns.len()
665    }
666
667    pub fn is_complete(&self) -> bool {
668        self.held >= self.size
669    }
670
671    pub fn bytes_held(&self) -> u64 {
672        self.held
673    }
674
675    /// The ranges that are complete on disk, as `(lo, hi)` pairs.
676    ///
677    /// This is the complement of the unassigned set minus what is still in flight, and
678    /// it is what a resume record must contain. Reporting only a byte COUNT is not
679    /// enough: positioned writes land ranges out of order, so "2 MB held" says nothing
680    /// about which 2 MB, and a resume that assumed a contiguous prefix would skip holes
681    /// and silently corrupt the file.
682    pub fn held_ranges(&self) -> Vec<(u64, u64)> {
683        // Start from everything, then subtract what is unassigned and what is
684        // outstanding on a connection; what remains has arrived.
685        let mut done = IntervalSet::full(self.size);
686        for r in self.unassigned.ranges() {
687            done.remove(r.lo, r.hi);
688        }
689        for c in &self.conns {
690            if let Some(r) = c.range {
691                // Bytes before the cursor have arrived; the rest has not.
692                done.remove(c.pos, r.hi);
693            }
694            if let Some(q) = c.queued {
695                done.remove(q.lo, q.hi);
696            }
697        }
698        done.ranges().iter().map(|r| (r.lo, r.hi)).collect()
699    }
700
701    /// Coverage audit: held + outstanding + unassigned == size.
702    ///
703    /// This is a SAFETY invariant and it does NOT imply liveness -- the
704    /// livelock this code is written to avoid (a fully-stolen range leaving a
705    /// connection idle with a non-empty queue) satisfies it at every instant.
706    /// `liveness_holds` is the property that matters.
707    /// The largest measured request setup cost across sources, in seconds.
708    ///
709    /// Exposed because a transport-layer watchdog must express its patience in
710    /// units of what a request actually costs on this path rather than as a
711    /// hardcoded constant: `delta` differs by an order of magnitude between a
712    /// LAN mirror and a TLS connection through a proxy, and a fixed timeout is
713    /// either trigger-happy on the slow path or useless on the fast one.
714    ///
715    /// This is the same quantity the repair deadband is built from
716    /// (`theta = scale * sqrt(delta * T_rem / n)`), so a client that widens
717    /// `delta` widens both together, which is the intended coupling.
718    pub fn worst_delta(&self) -> f64 {
719        self.sources
720            .iter()
721            .map(|s| s.delta_est)
722            .fold(0.0f64, f64::max)
723    }
724
725    /// The configured stall timeout, in seconds.
726    pub fn stall_timeout(&self) -> f64 {
727        self.stall_timeout
728    }
729
730    pub fn coverage_holds(&self) -> bool {
731        let outstanding: u64 = self.conns.iter().map(|c| c.outstanding()).sum();
732        self.held + outstanding + self.unassigned.total() == self.size
733            && self.unassigned.invariant_holds()
734    }
735
736    /// True when some enabled transition strictly decreases the unheld-byte count.
737    /// False means the scheduler is stuck.
738    pub fn liveness_holds(&self) -> bool {
739        if self.is_complete() {
740            return true;
741        }
742        // progress possible if: someone is receiving, or work is assignable,
743        // or a connection holds a queue it can start, or a stall can be reclaimed
744        self.conns.iter().any(|c| c.busy() && !c.stalled)
745            || !self.unassigned.is_empty()
746            || self.conns.iter().any(|c| c.queued.is_some())
747            || self.conns.iter().any(|c| c.stalled)
748    }
749
750    // ---------------------------------------------------------------- input
751
752    /// Record `n` bytes arriving on `conn` at time `now` over `dt` seconds.
753    ///
754    /// Convenience wrapper that assumes the arrival is contiguous at the
755    /// connection's cursor. Real transports must use [`Scheduler::on_bytes_at`]:
756    /// a response still draining from a range that was completed or stolen would
757    /// otherwise be credited against whatever range the connection holds NOW,
758    /// silently advancing a cursor over bytes that never arrived and leaving a
759    /// hole of zeros in the output file.
760    pub fn on_bytes(&mut self, conn: usize, n: u64, now: f64, dt: f64) {
761        let at = self.conns[conn].pos;
762        self.on_bytes_at(conn, at, n, now, dt);
763    }
764
765    /// Record `n` bytes that landed at absolute offset `off`.
766    ///
767    /// Arrivals that do not begin exactly at the connection's cursor are stale
768    /// (they belong to a superseded request) and are discarded: the bytes are
769    /// still written to the file by the transport, but they are not credited,
770    /// so the scheduler's coverage accounting stays exact.
771    pub fn on_bytes_at(&mut self, conn: usize, off: u64, n: u64, now: f64, dt: f64) {
772        let c = &mut self.conns[conn];
773        let Some(r) = c.range else { return };
774        if off != c.pos || off < r.lo {
775            return; // stale arrival from a superseded range
776        }
777        // ---- and stale by TIME, not only by offset --------------------------
778        //
779        // Matching the cursor is not enough to prove an arrival belongs to the
780        // request in flight. When a connection is reclaimed and re-requested, the
781        // new request starts at exactly the cursor the old one stopped at — so
782        // the last writes of the aborted request, still in the caller's queue,
783        // land at precisely the offset the new request is waiting for.
784        //
785        // Crediting them is not a coverage error (the bytes are on disk) but it
786        // desynchronises the connection: the cursor moves past where the new
787        // response begins, so every arrival that response produces fails the test
788        // above and is discarded. The connection then delivers bytes that are
789        // never counted, reads as silent, and is rescued only by the stall
790        // timeout — seconds of dead air, and the transfer visibly frozen for them
791        // once the endgame has left one connection carrying the remainder.
792        //
793        // A request cannot be answered before it was issued, so the arrival's own
794        // timestamp settles it.
795        if now < c.started_at {
796            return;
797        }
798        let room = r.hi.saturating_sub(c.pos);
799        let step = n.min(room);
800        if step == 0 {
801            return;
802        }
803        c.pos += step;
804        self.held += step;
805        c.last_progress = now;
806        c.stalled = false;
807        let src = c.source;
808        self.sources[src].consecutive_stalls = 0;
809        if dt > 0.0 {
810            // Accumulate, and only take a rate sample once the window has enough
811            // wall clock in it to mean something.
812            c.rate_acc_bytes += step;
813            c.rate_acc_dt += dt;
814            if c.rate_acc_dt >= RATE_WINDOW {
815                let sample = c.rate_acc_bytes as f64 / c.rate_acc_dt;
816                c.rate_acc_bytes = 0;
817                c.rate_acc_dt = 0.0;
818                c.detector.observe_rate(sample);
819                c.rate_est = if c.rate_est <= 0.0 {
820                    sample
821                } else {
822                    RATE_ALPHA * sample + (1.0 - RATE_ALPHA) * c.rate_est
823                };
824            }
825        }
826        if c.pos >= r.hi {
827            c.range = None;
828        }
829    }
830
831    /// Suspend a source (429/503 with Retry-After) and reclaim its ranges.
832    pub fn suspend_source(&mut self, src: usize, until: f64) {
833        self.sources[src].suspended_until = until;
834        let idxs: Vec<usize> = (0..self.conns.len())
835            .filter(|&j| self.conns[j].source == src)
836            .collect();
837        for j in idxs {
838            self.reclaim(j);
839        }
840    }
841
842    // ------------------------------------------------------- source failover
843
844    /// Take a source out of the transfer permanently and reclaim its ranges.
845    ///
846    /// # Why permanent removal is a different operation from suspension
847    ///
848    /// [`suspend_source`](Self::suspend_source) is a pause: the source comes back
849    /// when the clock says so, which is right for a `429` or a transient stall.
850    /// Some failures are not pauses. A host that no longer resolves, a mirror
851    /// serving a different build, a TLS certificate that will not validate — none
852    /// of those get better on a timer, and retrying them costs a full setup every
853    /// backoff for the rest of the download.
854    ///
855    /// Retiring reclaims whatever the source's connections were holding, so those
856    /// bytes go back to the unassigned set and the surviving connections pick
857    /// them up through the ordinary work-conserving path. Nothing is stranded.
858    ///
859    /// Returns the connection indices that were freed, so the caller can tear
860    /// down their sockets. Retiring the LAST live source is permitted — a
861    /// transfer with nowhere left to fetch from has to be able to say so, and
862    /// [`live_sources`](Self::live_sources) returning zero is how the caller
863    /// learns it — but it is never something the scheduler does on its own.
864    pub fn retire_source(&mut self, src: usize) -> Vec<usize> {
865        let Some(s) = self.sources.get_mut(src) else {
866            return Vec::new();
867        };
868        if !s.live {
869            return Vec::new();
870        }
871        s.live = false;
872        let idxs: Vec<usize> = (0..self.conns.len())
873            .filter(|&j| self.conns[j].source == src)
874            .collect();
875        for &j in &idxs {
876            self.reclaim(j);
877        }
878        idxs
879    }
880
881    /// Point a retired source's connections at a replacement mirror.
882    ///
883    /// # The reserve bench
884    ///
885    /// This is what a mirror list is worth. A Metalink for a distribution image
886    /// commonly names fifteen to twenty mirrors, while politeness and physics
887    /// together justify perhaps four connections — so most of the list is not a
888    /// source, it is a *reserve*. Without a way to substitute, those reserves are
889    /// decoration: the transfer either survives on the mirrors it opened with or
890    /// it does not.
891    ///
892    /// Substituting in place, rather than growing the connection set, is what
893    /// keeps the aggregate socket count equal to what politeness authorised. The
894    /// replaced source's connections are reused as they are — same indices, same
895    /// detector state reset — so the caller's own per-connection bookkeeping
896    /// (hostnames for the progress view, per-connection targets) stays index-
897    /// aligned with the scheduler's.
898    ///
899    /// The replacement starts with the incoming `Source`'s estimates and a clean
900    /// stall count. Carrying the dead mirror's `gamma_est` across would price the
901    /// new host by the old one's failure and bias the very next repair against
902    /// it.
903    ///
904    /// Returns the connection indices now belonging to the replacement, empty if
905    /// `src` does not exist.
906    pub fn replace_source(&mut self, src: usize, mut replacement: Source) -> Vec<usize> {
907        if src >= self.sources.len() {
908            return Vec::new();
909        }
910        // Reclaim first: whatever the outgoing source held must go back to the
911        // unassigned set before its connections are relabelled, or those bytes
912        // are owned by a connection that will never be asked for them again.
913        let idxs: Vec<usize> = (0..self.conns.len())
914            .filter(|&j| self.conns[j].source == src)
915            .collect();
916        for &j in &idxs {
917            self.reclaim(j);
918        }
919        replacement.live = true;
920        replacement.consecutive_stalls = 0;
921        replacement.suspended_until = 0.0;
922        self.sources[src] = replacement;
923        for &j in &idxs {
924            let c = &mut self.conns[j];
925            // A fresh detector: the collapse grade belongs to the host that
926            // earned it, and inheriting it would have the new mirror graded
927            // Suspect before its first byte — which bars it from ever being
928            // chosen as a repair taker.
929            c.detector = crate::detect::CollapseDetector::default();
930            c.rate_est = 0.0;
931            c.stalled = false;
932        }
933        idxs
934    }
935
936    /// Sources that have not been retired.
937    pub fn live_sources(&self) -> usize {
938        self.sources.iter().filter(|s| s.live).count()
939    }
940
941    /// Is this source still in the transfer?
942    pub fn source_is_live(&self, src: usize) -> bool {
943        self.sources.get(src).is_some_and(|s| s.live)
944    }
945
946    /// Consecutive stalls charged against a source, for a caller deciding
947    /// whether to substitute a reserve mirror for it.
948    pub fn source_stalls(&self, src: usize) -> u32 {
949        self.sources
950            .get(src)
951            .map(|s| s.consecutive_stalls)
952            .unwrap_or(0)
953    }
954
955    /// The publisher-stated preference a source was created with.
956    pub fn source_priority(&self, src: usize) -> u32 {
957        self.sources
958            .get(src)
959            .map(|s| s.priority)
960            .unwrap_or(NO_PRIORITY)
961    }
962
963    /// How many sources this transfer was built with, live or retired.
964    pub fn n_sources(&self) -> usize {
965        self.sources.len()
966    }
967
968    /// A connection's transport failed: reclaim its range NOW, and hold that
969    /// connection back for `retry_after` seconds.
970    ///
971    /// # Why silence is not the right signal for a failure
972    ///
973    /// The stall timeout exists to grade a connection that is *delivering
974    /// nothing*, and it has to be patient — several seconds at least, scaled to
975    /// the measured setup cost, because a slow path is not a broken one. A fetch
976    /// that has already returned an error needs none of that patience: the
977    /// question the timeout is there to answer has been answered, by the
978    /// transport, definitively.
979    ///
980    /// Without this the two are conflated, and the cost is paid in whole stall
981    /// timeouts. A connection whose socket was closed by the peer, whose body was
982    /// truncated, or whose request was refused looks exactly like a slow one, so
983    /// the range is not re-requested for 4-45 s (the range `stall_timeout` covers
984    /// on real paths). Early in a transfer the other connections cover for it and
985    /// nothing is visible; at the end, when the remaining work has concentrated
986    /// onto one or two connections, the whole transfer freezes for it — the
987    /// reported "downloads stall past 90%, transfer rate falls to zero, every
988    /// connection shows disconnected" failure.
989    ///
990    /// `retry_after` is the caller's backoff for THIS connection only. The range
991    /// goes back to the unassigned set immediately either way, so an idle
992    /// connection can pick it up on the next tick without waiting for it.
993    pub fn on_conn_error(&mut self, conn: usize, now: f64, retry_after: f64) {
994        if conn >= self.conns.len() {
995            return;
996        }
997        self.reclaim(conn);
998        let until = now + retry_after.max(0.0);
999        let c = &mut self.conns[conn];
1000        c.setup_end = until;
1001        // The stall clock starts when the connection is allowed to work again;
1002        // otherwise the backoff it was told to take is charged against it as
1003        // silence and it is graded stalled the moment it comes back.
1004        c.last_progress = until;
1005    }
1006
1007    fn reclaim(&mut self, j: usize) {
1008        let c = &mut self.conns[j];
1009        if let Some(r) = c.range {
1010            if c.pos < r.hi {
1011                let back = Range::new(c.pos, r.hi);
1012                c.range = None;
1013                let q = c.queued.take();
1014                self.unassigned.insert(back);
1015                if let Some(q) = q {
1016                    self.unassigned.insert(q);
1017                }
1018                self.stats.reclaims += 1;
1019            } else {
1020                c.range = None;
1021            }
1022        } else if let Some(q) = c.queued.take() {
1023            self.unassigned.insert(q);
1024            self.stats.reclaims += 1;
1025        }
1026        let c = &mut self.conns[j];
1027        c.rate_est = 0.0;
1028        c.stalled = true;
1029    }
1030
1031    // ---------------------------------------------------------------- tick
1032
1033    /// Advance the scheduler. Returns the actions the caller must perform.
1034    pub fn tick(&mut self, now: f64) -> Vec<Action> {
1035        let mut acts = Vec::new();
1036
1037        if !self.started {
1038            self.initial_split(now, &mut acts);
1039            self.started = true;
1040            return acts;
1041        }
1042
1043        // ---- feed wall-clock silence to the detectors ----------------------
1044        // A connection delivering nothing produces no rate samples at all, so
1045        // silence is evidence that only the clock can supply. Grading it here
1046        // lets repair pre-empt at half the stall timeout instead of waiting for
1047        // the full timeout to expire.
1048        for j in 0..self.conns.len() {
1049            let c = &self.conns[j];
1050            if c.busy() && now >= c.setup_end {
1051                let quiet = now - c.last_progress.max(c.setup_end);
1052                let st = self.stall_timeout;
1053                self.conns[j].detector.observe_silence(quiet, st);
1054            }
1055        }
1056
1057        // ---- liveness path 1: reclaim stalled connections -----------------
1058        //
1059        // Collected into a reused buffer rather than a fresh `Vec` each tick. The
1060        // indices cannot be reclaimed in the same pass that finds them — `reclaim`
1061        // takes `&mut self` while the filter borrows `self.conns` — so the two-phase
1062        // shape stays, but the allocation does not have to. `std::mem::take` moves the
1063        // buffer out so the loop below can hold it while `self` is borrowed mutably,
1064        // and it is put back at the end for the next tick.
1065        let mut stalled = std::mem::take(&mut self.scratch_idx);
1066        stalled.clear();
1067        stalled.extend((0..self.conns.len()).filter(|&j| self.conn_stalling(j, now)));
1068        for j in stalled.drain(..) {
1069            self.reclaim(j);
1070            acts.push(Action::Cancel { conn: j });
1071            // A source that keeps stalling must be suspended, not merely
1072            // retried: otherwise work-conserving assignment hands it the same
1073            // bytes repeatedly without making forward progress.
1074            let src = self.conns[j].source;
1075            self.sources[src].consecutive_stalls += 1;
1076            let k = self.sources[src].consecutive_stalls;
1077            if k >= 2 {
1078                let mut backoff = (self.stall_timeout * (1u64 << (k - 2).min(5)) as f64).min(30.0);
1079                // Never suspend the LAST usable source for longer than a caller's
1080                // watchdog will wait. Exponential backoff is right when there is
1081                // somewhere else to send the work; when this is the only source it
1082                // is a self-inflicted outage, and a transport that fails on silence
1083                // cannot tell it apart from the source being gone.
1084                //
1085                // Callers should also consult `all_sources_suspended_until` so a
1086                // planned pause is not charged against a no-progress deadline. This
1087                // clamp is the second line of defence: it keeps the invariant local
1088                // to the scheduler, so a caller that does not know about deliberate
1089                // suspension still cannot be starved by it.
1090                if self.sources.len() == 1 {
1091                    backoff = backoff.min(self.stall_timeout.max(1.0));
1092                }
1093                self.sources[src].suspended_until = now + backoff;
1094            }
1095        }
1096
1097        // ---- liveness path 2: an idle connection holding a queue MUST start it
1098        //
1099        // Mandatory: a connection whose active range was entirely stolen goes
1100        // idle WITHOUT completing, so the completion path in on_bytes never fires
1101        // and the queued bytes would be owned by an idle connection that never
1102        // requests them.
1103        for j in 0..self.conns.len() {
1104            if !self.conns[j].busy()
1105                && self.conns[j].queued.is_some()
1106                && now >= self.conns[j].setup_end
1107            {
1108                let r = self.conns[j].queued.take().unwrap();
1109                self.start(j, r, now);
1110                acts.push(Action::Request { conn: j, range: r });
1111            }
1112        }
1113
1114        // ---- divergence-triggered repair ---------------------------------
1115        let theta = self.theta(now);
1116        let mut divergent = false;
1117        for _ in 0..MAX_REPAIRS_PER_TICK {
1118            let Some((vi, ti)) = self.pick_victim_taker(now) else {
1119                break;
1120            };
1121            let (v_eta, t_eta) = (self.conns[vi].eta(), self.conns[ti].eta());
1122            // Explicit ordering test: an unknown ETA yields NaN, and a NaN
1123            // divergence must NOT trigger a repair (a repair costs a full delta,
1124            // so acting on an unmeasured quantity is strictly a loss).
1125            if !matches!(
1126                (v_eta - t_eta).partial_cmp(&theta),
1127                Some(core::cmp::Ordering::Greater)
1128            ) {
1129                break;
1130            }
1131            divergent = true;
1132            // Between two busy connections the divergence must PERSIST; see
1133            // `REPAIR_PERSIST`. A collapsing victim and an idle taker are exempt.
1134            let collapsing = self.conns[vi].detector.health().is_suspect_or_worse();
1135            if self.conns[ti].busy() && !collapsing {
1136                // Persistence in units of the setup cost as well as of samples:
1137                // a divergence that is really two flows at different points of
1138                // slow start lasts a number of round trips, and a fixed window
1139                // is one length on a LAN and another on a transatlantic path.
1140                let delta = self
1141                    .sources
1142                    .iter()
1143                    .map(|s| s.delta_est)
1144                    .fold(0.0f64, f64::max);
1145                let persist = REPAIR_PERSIST.max(REPAIR_PERSIST_DELTAS * delta);
1146                match self.repair_candidate {
1147                    Some((v, since)) if v == vi => {
1148                        if now - since < persist {
1149                            break;
1150                        }
1151                    }
1152                    _ => {
1153                        self.repair_candidate = Some((vi, now));
1154                        break;
1155                    }
1156                }
1157            }
1158            if self.conns[ti].queued.is_some() {
1159                break;
1160            }
1161            let Some(vr) = self.conns[vi].range else {
1162                break;
1163            };
1164            let left = vr.hi.saturating_sub(self.conns[vi].pos) as f64;
1165            let rv = self.conns[vi].rate_est;
1166            let rt = self.conns[ti].rate_est;
1167            let delta = self.sources[self.conns[ti].source].delta_est;
1168            // Equalise projected finishes, charging the taker one setup:
1169            //   (left - x)/rv == t_eta + delta + x/rt
1170            let x = if rv <= 0.0 {
1171                // Victim is stalled: hand over everything it has not received.
1172                left
1173            } else if rt <= 0.0 {
1174                0.0
1175            } else {
1176                ((left / rv - t_eta - delta) * (rv * rt) / (rv + rt)).clamp(0.0, left)
1177            };
1178            if x <= STEAL_QUANTUM as f64 {
1179                break;
1180            }
1181            if self.trace_repair {
1182                eprintln!(
1183                    "repair: t={now:.2} victim={vi} (eta {v_eta:.2}s rate {:.1}MB/s left {:.1}MB {:?} rising={}) \
1184                     taker={ti} (eta {t_eta:.2}s rate {:.1}MB/s busy={}) theta={theta:.2} x={:.1}MB",
1185                    rv / 1e6,
1186                    left / 1e6,
1187                    self.conns[vi].detector.health(),
1188                    self.conns[vi].detector.rising(),
1189                    rt / 1e6,
1190                    self.conns[ti].busy(),
1191                    x / 1e6
1192                );
1193            }
1194
1195            // ---- does this repair actually pay for itself? -------------------
1196            //
1197            // The equalisation above solves `(left - x)/rv == t_eta + delta + x/rt`,
1198            // which treats `rt` as capacity that `x` bytes can be moved ONTO. That
1199            // is true when the connections have independent bottlenecks — separate
1200            // mirrors, separate paths. It is false in the case that dominates real
1201            // use: several connections to one origin, sharing one bottleneck. There
1202            // the taker's rate is not spare capacity, it is a share of the same
1203            // capacity the victim is using, so moving bytes across does not make
1204            // them arrive faster. It only re-labels which connection carries them,
1205            // and charges a setup for the privilege.
1206            //
1207            // Worse, the per-connection rate divergence that triggers the repair is
1208            // largely a property of the PATH, not of the assignment: flows sharing
1209            // a bottleneck settle at persistently unequal shares (roughly 1/RTT,
1210            // with cwnd history making the asymmetry outlive any round trip). A
1211            // repair cannot move that. So the divergence survives the repair, and
1212            // re-triggers it.
1213            //
1214            // The test: compare the makespan now against the makespan after, where
1215            // "after" charges the setup and credits only the improvement in the
1216            // WORST finishing time — because the makespan is a max, not a sum, and
1217            // improving anything other than the laggard buys nothing.
1218            let makespan_now =
1219                self.conns
1220                    .iter()
1221                    .map(|c| c.eta())
1222                    .fold(0.0f64, |a, b| if b > a { b } else { a });
1223            // The victim keeps `left - x` at its own rate; the taker takes on `x`
1224            // after paying `delta`, on top of what it already owes.
1225            let v_after = if rv > 0.0 {
1226                (left - x) / rv
1227            } else {
1228                f64::INFINITY
1229            };
1230            let t_after = if rt > 0.0 {
1231                t_eta + delta + x / rt
1232            } else {
1233                f64::INFINITY
1234            };
1235            // Every other connection is unaffected by this particular exchange.
1236            let others = self
1237                .conns
1238                .iter()
1239                .enumerate()
1240                .filter(|(j, _)| *j != vi && *j != ti)
1241                .map(|(_, c)| c.eta())
1242                .fold(0.0f64, |a, b| if b > a { b } else { a });
1243            let makespan_after = v_after.max(t_after).max(others);
1244            // Require the gain to exceed the setup it costs, not merely to be
1245            // positive: a repair that improves the projected makespan by less than
1246            // one delta has not accounted for its own price. `theta` above is the
1247            // hysteresis that stops oscillation; this is the profitability test,
1248            // and both are needed — the first keeps jitter from triggering repair,
1249            // the second keeps a real-but-unprofitable divergence from doing so.
1250            // Explicit ordering, matching the theta test above: an unmeasured rate
1251            // makes this difference NaN, and a NaN must REFUSE the repair rather
1252            // than fall through either way. Acting on an unmeasured quantity is
1253            // strictly a loss, because the setup cost is certain and the gain is not.
1254            if !matches!(
1255                (makespan_now - makespan_after).partial_cmp(&delta),
1256                Some(core::cmp::Ordering::Greater)
1257            ) {
1258                break;
1259            }
1260
1261            let x = x as u64;
1262            let new_hi = vr.hi - x;
1263            let stolen = Range::new(new_hi, vr.hi);
1264            // Client-side shrink: the victim's target end moves and the server is
1265            // never told. Free on the WIRE — no cancellation, no round trip — but
1266            // only if the local fetch loop is told, which is what `Shrink` does.
1267            // Without it the victim streams the stolen span anyway; see the
1268            // `Action::Shrink` docs for what that costs.
1269            self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
1270            self.conns[ti].queued = Some(stolen);
1271            acts.push(Action::Shrink {
1272                conn: vi,
1273                hi: new_hi,
1274            });
1275            self.stats.repairs += 1;
1276            // The next repair earns its own persistence: a shrink changes every
1277            // ETA that justified this one, and a cascade of repairs each riding
1278            // on the previous one's disturbance is the storm this damps.
1279            self.repair_candidate = None;
1280        }
1281        if !divergent {
1282            self.repair_candidate = None;
1283        }
1284
1285        // ---- work-conserving assignment (Lemma 2) -------------------------
1286        //
1287        // A connection is DORMANT once the budget is spent: it is skipped here, so
1288        // it is never given work and never opens a socket. This is the whole
1289        // mechanism behind the in-band concurrency ramp — raising the limit makes
1290        // the next tick admit a connection through this ordinary path, and
1291        // lowering it lets an already-busy connection finish its range and then go
1292        // quiet, with no cancellation and no wasted bytes.
1293        //
1294        // The budget is spent by COUNT (`admitted`), not by index. All connections
1295        // are dispatched at once — a fixed `-x N`, or the opening burst before any
1296        // refusal has taught the transfer anything — so which ones an origin
1297        // happens to grant is not correlated with index at all. Gating eligibility
1298        // on `j < active_limit` let a refusal-driven cap retire a connection the
1299        // origin was actively serving just because its index was too high, while
1300        // leaving a lower-index connection that was cooling down from its OWN
1301        // refusal as the only thing still allowed to pick up new work — collapsing
1302        // realised concurrency below what the origin would serve, which is the one
1303        // thing this cap exists to prevent. See `admitted` for what counts.
1304        //
1305        // Candidates are visited proven connections first, unproven ones after —
1306        // "proven" meaning `rate_est > 0.0`, which only a connection that has
1307        // actually delivered bytes on this source carries; a reclaim resets it to
1308        // zero. Plain index order reopens the exact bug above from the other
1309        // side: the moment one of two settled, working connections finishes a
1310        // chunk and goes idle for the one tick before this loop re-admits it, it
1311        // is indistinguishable BY INDEX from a connection that has never
1312        // delivered a byte and is only here because its OWN refusal cooldown
1313        // happens to have expired on the same tick. Whichever has the lower
1314        // index wins the freed slot — sometimes the untested one — and an origin
1315        // that only ever grants the same two connections now refuses the
1316        // newcomer, while the settled connection that actually earned the slot
1317        // sits idle for another tick waiting its turn. Repeated over a transfer's
1318        // life this is exactly the churn the ceiling exists to stop, just paid
1319        // in requests instead of in stranded concurrency.
1320        let mut order = std::mem::take(&mut self.scratch_order);
1321        order.clear();
1322        // One predicate and its exact complement, rather than `> 0.0` and
1323        // `<= 0.0`: the two passes must partition the connections, and BOTH of
1324        // those comparisons are false for a NaN rate. A connection that fell into
1325        // neither would be dropped from the visit order entirely — never assigned
1326        // work, and never reclaimed into service either, since reclaim only fires
1327        // on connections that HOLD a range. Silent permanent idleness is not a
1328        // failure mode worth leaving to the float rules.
1329        let proven = |c: &Conn| c.rate_est > 0.0;
1330        order.extend((0..self.conns.len()).filter(|&j| proven(&self.conns[j])));
1331        order.extend((0..self.conns.len()).filter(|&j| !proven(&self.conns[j])));
1332        // Connections that will want work on a LATER tick: idle, holding nothing,
1333        // and held back by their own cooldown or a suspended source rather than by
1334        // anything this pass can resolve. These are who the reserve below is for.
1335        // Counted once: assigning work in the loop only turns reachable
1336        // connections busy, and those were never in this set.
1337        let waiting = (0..self.conns.len())
1338            .filter(|&k| {
1339                let c = &self.conns[k];
1340                !c.busy()
1341                    && c.queued.is_none()
1342                    && (now < c.setup_end || !self.sources[c.source].usable_at(now))
1343            })
1344            .count();
1345        let mut admitted = self.admitted();
1346        for &j in &order {
1347            if admitted >= self.active_limit {
1348                break;
1349            }
1350            if self.conns[j].busy() || now < self.conns[j].setup_end {
1351                continue;
1352            }
1353            let src = self.conns[j].source;
1354            if !self.sources[src].usable_at(now) {
1355                continue;
1356            }
1357            // How much to hand this connection.
1358            //
1359            // `u64::MAX` — take everything — is right once concurrency has settled:
1360            // maximal ranges mean the fewest requests, which is the whole point of
1361            // range scheduling. It is wrong while more admissions are still
1362            // expected, because the first idle connection would swallow the
1363            // reserve that connections admitted later are supposed to pick up, and
1364            // they would be left to STEAL from it. That is a repair per admission,
1365            // and the repair undoes a split that had just been made for no reason.
1366            //
1367            // So while room remains, hand out a budget-sized share and leave the
1368            // rest. The cost of being wrong in this direction is one extra request
1369            // later — now nearly free on a pooled connection — against one repair
1370            // per admitted connection the other way.
1371            //
1372            // Reserve only for connections THIS LOOP CANNOT REACH. An idle
1373            // connection that is merely further down the visit order is not one
1374            // of them — it gets its work in this same pass, so holding a share
1375            // back for it just splits one request into two.
1376            //
1377            // Two things put a connection out of reach:
1378            //
1379            // * the ramp has not admitted it yet — `active_limit < ceiling`. This
1380            //   is every `--adaptive` transfer, which starts at one connection
1381            //   with the rest of the budget ahead of it. Without this clause the
1382            //   reserve `initial_split` holds back is swallowed on the first tick
1383            //   after that connection drains its quota, and every later admission
1384            //   can only steal — see
1385            //   `a_ramping_connection_that_drains_its_quota_does_not_swallow_the_reserve`.
1386            //
1387            // * it is `waiting`: idle, but held off by its own cooldown or a
1388            //   suspended source, with a seat under the current limit still free.
1389            //   This is the throttled case — the cap has just widened on a
1390            //   successful probe, and the connections that will fill the new seats
1391            //   are still cooling down from the refusal that taught the old one.
1392            //   `+ 1` because `j` itself is not yet counted in `admitted`.
1393            //
1394            // Testing `admitted` against `active_limit` ALONE is wrong in a way no
1395            // existing test caught: `active_limit` is `usize::MAX` for every caller
1396            // that never opted into the ramp, so the comparison is vacuously true,
1397            // the share path swallows the fixed `-x N` case whole, and the maximal
1398            // branch below becomes unreachable. Hence `limit`, and hence
1399            // `settled_concurrency_hands_out_maximal_ranges_not_shares`.
1400            let ceiling = self.ceiling();
1401            let limit = self.active_limit.min(self.conns.len());
1402            let want = if self.active_limit < ceiling || (waiting > 0 && admitted + 1 < limit) {
1403                let remaining = self.unassigned.total();
1404                let share = remaining / ceiling as u64;
1405                share.max(STEAL_QUANTUM * 4)
1406            } else {
1407                u64::MAX
1408            };
1409            if let Some(r) = self.unassigned.take_front(want) {
1410                self.start(j, r, now);
1411                acts.push(Action::Request { conn: j, range: r });
1412                admitted += 1;
1413                continue;
1414            }
1415            // Nothing unassigned: steal from the worst laggard.
1416            //
1417            // This is the steal-half heuristic, and it fires on a DIFFERENT
1418            // trigger from the divergence repair above: not "the finishes have
1419            // diverged" but "a connection has gone idle and there is nothing left
1420            // to give it". Splitting the laggard's remainder down the middle is the
1421            // right move when the idle connection has capacity the laggard cannot
1422            // use. It is churn when they share one bottleneck — the same span is
1423            // re-requested, a setup is paid, and the aggregate rate is unchanged
1424            // because it was never the assignment that limited it.
1425            //
1426            // So the same profitability test applies. An idle connection is not a
1427            // reason to move work; it is a reason to ASK whether moving work helps.
1428            if let Some(vi) = self.worst_busy(j) {
1429                let vr = self.conns[vi].range.unwrap();
1430                let left = vr.hi.saturating_sub(self.conns[vi].pos);
1431                let half = left / 2;
1432                // Will the taker, paying one setup, actually finish this half
1433                // sooner than the victim would have finished the whole remainder?
1434                // With `rt` unknown (a connection that has just gone idle may have
1435                // no estimate yet) fall back to the victim's own rate, which makes
1436                // the test neutral rather than optimistic.
1437                let rv = self.conns[vi].rate_est;
1438                let rt = if self.conns[j].rate_est > 0.0 {
1439                    self.conns[j].rate_est
1440                } else {
1441                    rv
1442                };
1443                let delta = self.sources[self.conns[j].source].delta_est;
1444                let worth_it = if rv <= 0.0 {
1445                    // The victim is delivering nothing measurable: anything is better.
1446                    true
1447                } else if rt <= 0.0 {
1448                    false
1449                } else {
1450                    let before = left as f64 / rv;
1451                    let after = (half as f64 / rv).max(delta + half as f64 / rt);
1452                    before - after > delta
1453                };
1454                if half > STEAL_QUANTUM && worth_it {
1455                    let new_hi = vr.hi - half;
1456                    self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
1457                    let stolen = Range::new(new_hi, vr.hi);
1458                    // Same shrink discipline as the divergence repair above: the
1459                    // victim must be told its far end moved, or it streams the
1460                    // half we just handed away.
1461                    acts.push(Action::Shrink {
1462                        conn: vi,
1463                        hi: new_hi,
1464                    });
1465                    self.start(j, stolen, now);
1466                    acts.push(Action::Request {
1467                        conn: j,
1468                        range: stolen,
1469                    });
1470                    admitted += 1;
1471                    self.stats.repairs += 1;
1472                }
1473            }
1474            // NOTE: no hedging. Redundant requests waste bandwidth on non-erasure channels.
1475        }
1476
1477        self.stats.bytes_held = self.held;
1478        // Hand the scratch buffers back so their capacity survives to the next tick.
1479        // Without this the `mem::take` above would leave an empty Vec in the field and
1480        // the next tick would allocate again — the reuse would be nominal only.
1481        self.scratch_idx = stalled;
1482        self.scratch_order = order;
1483        acts
1484    }
1485
1486    fn start(&mut self, j: usize, r: Range, now: f64) {
1487        let delta = self.sources[self.conns[j].source].delta_est;
1488        let c = &mut self.conns[j];
1489        c.range = Some(r);
1490        c.pos = r.lo;
1491        c.started_at = now;
1492        c.setup_end = now + delta;
1493        c.last_progress = now + delta;
1494        c.stalled = false;
1495        self.stats.requests += 1;
1496    }
1497
1498    fn initial_split(&mut self, now: f64, acts: &mut Vec<Action>) {
1499        // Maximal ranges, proportional to rate estimate where known, else equal.
1500        //
1501        // Only the ACTIVE prefix takes part. With the ramp enabled the transfer
1502        // opens one connection, and the rest are admitted by `set_active_limit` as
1503        // the in-band search finds them worth their setup cost. Splitting the
1504        // object across connections that will not run would strand those bytes in
1505        // a quota nobody fetches.
1506        let n = self.conns.len().min(self.active_limit);
1507        if n == 0 || self.size == 0 {
1508            return;
1509        }
1510        let weights: Vec<f64> = self
1511            .conns
1512            .iter()
1513            .take(n)
1514            .map(|c| {
1515                let src = &self.sources[c.source];
1516                let g = if src.gamma_est > 0.0 {
1517                    src.gamma_est
1518                } else {
1519                    1.0
1520                };
1521                // The publisher's ranking enters HERE and only here. This is the
1522                // one moment in the transfer at which nothing has been measured,
1523                // so a stated preference is the best information available; from
1524                // the next tick onward `gamma_est` and the per-connection rate
1525                // estimates are real samples and the prior is not consulted
1526                // again. See `Source::priority`.
1527                g * src.priority_weight()
1528            })
1529            .collect();
1530        let total: f64 = weights.iter().sum();
1531
1532        // Split what is ACTUALLY unassigned, not `[0, size)`.
1533        //
1534        // An earlier version partitioned the whole object arithmetically, which
1535        // silently ignored `mark_done`. That broke both features that depend on
1536        // it: `--range` fetched from offset 0 instead of the requested interval,
1537        // and `--continue` re-fetched bytes already on disk. The unassigned set is
1538        // the single source of truth for what remains, so the split must be taken
1539        // from it.
1540        let remaining: Vec<Range> = self.unassigned.ranges().to_vec();
1541        let avail: u64 = remaining.iter().map(|r| r.hi - r.lo).sum();
1542        if avail == 0 {
1543            return;
1544        }
1545        // Per-connection byte quotas, proportional to rate estimate.
1546        //
1547        // Divided over the FULL connection budget, not just the active prefix, and
1548        // this matters specifically when the ramp is running. With one connection
1549        // active, dividing by the active count alone hands that connection the
1550        // entire object — so a connection admitted later finds the unassigned set
1551        // empty and its only route to work is to STEAL, which pays a repair to
1552        // undo a split that should never have been made. Measured cost of getting
1553        // this wrong: every ramped transfer of a 3.15 MB object took ~21 s against
1554        // 6.3 s for fixed concurrency, and several were reported as failures
1555        // despite delivering byte-exact files.
1556        //
1557        // Quotas over the full budget leave the remainder UNASSIGNED, which is
1558        // exactly where a newly admitted connection takes work from through
1559        // ordinary work-conserving assignment — no repair, no steal, no duplicate
1560        // request. If the ramp never grows, nothing is lost: the active connection
1561        // finishes its quota and work-conserving assignment gives it the next
1562        // piece, which connection reuse now makes nearly free.
1563        let budget = self.ceiling();
1564        let mut quota: Vec<u64> = weights
1565            .iter()
1566            .map(|w| ((w / total) * (avail as f64 / budget as f64) * n as f64) as u64)
1567            .collect();
1568        // Rounding must not strand bytes — but only when every connection is
1569        // active. While ramping, the unclaimed remainder is deliberate.
1570        if n >= budget {
1571            let assigned: u64 = quota.iter().sum();
1572            if let Some(last) = quota.last_mut() {
1573                *last += avail.saturating_sub(assigned);
1574            }
1575        }
1576
1577        // Walk the unassigned ranges, carving each connection's quota out of them
1578        // in order. A connection may receive a range that is not contiguous with
1579        // its neighbours' — that is fine, since ranges are independent requests.
1580        let mut it = remaining.into_iter();
1581        let mut cur = it.next();
1582        for (j, want_total) in quota.iter().enumerate() {
1583            let mut want = *want_total;
1584            while want > 0 {
1585                let Some(seg) = cur else { break };
1586                let take = want.min(seg.hi - seg.lo);
1587                let r = Range::new(seg.lo, seg.lo + take);
1588                // A connection holds one active range plus a one-slot pipeline.
1589                // Anything beyond that stays UNASSIGNED rather than being stashed:
1590                // work-conserving assignment will hand it out as connections free
1591                // up, and leaving it in the set is what keeps the coverage
1592                // invariant checkable.
1593                if self.conns[j].range.is_none() {
1594                    self.unassigned.remove(r.lo, r.hi);
1595                    self.start(j, r, now);
1596                    acts.push(Action::Request { conn: j, range: r });
1597                } else if self.conns[j].queued.is_none() {
1598                    self.unassigned.remove(r.lo, r.hi);
1599                    self.conns[j].queued = Some(r);
1600                } else {
1601                    break;
1602                }
1603                want -= take;
1604                cur = if seg.hi - seg.lo > take {
1605                    Some(Range::new(seg.lo + take, seg.hi))
1606                } else {
1607                    it.next()
1608                };
1609            }
1610        }
1611    }
1612
1613    /// The current repair deadband, in seconds. Exposed for measurement.
1614    pub fn theta_now(&self, now: f64) -> f64 {
1615        self.theta(now)
1616    }
1617
1618    fn theta(&self, now: f64) -> f64 {
1619        // One fold, no allocation. This is called from the tick loop — 50 times a
1620        // second at the default 20 ms tick, for the whole transfer — and it collected
1621        // a `Vec<&Conn>` on every call only to take its length and sum one field.
1622        // Nothing here needs the intermediate collection.
1623        let (live_count, agg) = self
1624            .conns
1625            .iter()
1626            .filter(|c| self.sources[c.source].usable_at(now))
1627            .fold((0usize, 0.0f64), |(k, sum), c| {
1628                (k + 1, sum + c.rate_est.max(0.0))
1629            });
1630        let n = live_count.max(1) as f64;
1631        let agg = if agg > 0.0 { agg } else { 1.0 };
1632        let remaining = self.size.saturating_sub(self.held) as f64;
1633        let t_rem = remaining / agg;
1634        let delta = self
1635            .sources
1636            .iter()
1637            .map(|s| s.delta_est)
1638            .fold(0.0f64, f64::max);
1639        let band = self.theta_scale * (delta * t_rem.max(0.0) / n).sqrt();
1640
1641        // ---- floor the deadband at what a repair actually costs --------------
1642        //
1643        // `sqrt(delta * T_rem / n)` is the right SHAPE — it is the granularity
1644        // trade-off — but it is unbounded below, and it approaches zero from two
1645        // directions that both make repair a worse idea, not a better one:
1646        // `T_rem` shrinks as the transfer finishes, and `n` grows with
1647        // concurrency. So the deadband is narrowest exactly when a repair has the
1648        // least remaining time to earn its cost back and the most competitors to
1649        // pay it against.
1650        //
1651        // Measured on the shared-bottleneck harness (examples/storm.rs, 12 seeds):
1652        // theta reached 0.061-0.081 s against a delta of 0.12 s. Every repair
1653        // triggered in that regime spends one full setup to recover a divergence
1654        // smaller than the setup — a guaranteed loss, taken deliberately, dozens
1655        // of times per transfer.
1656        //
1657        // A repair cannot be worth making unless the divergence it corrects
1658        // exceeds what correcting it costs, so `delta` is the floor. This is not a
1659        // tuning constant: it is the break-even point, and it is measured per
1660        // source rather than guessed, so a high-RTT path widens it automatically.
1661        band.max(delta)
1662    }
1663
1664    fn pick_victim_taker(&self, now: f64) -> Option<(usize, usize)> {
1665        // Victim ranking is (health, ETA), health first. A connection the
1666        // detector has graded Suspect is a victim even when its *projected* ETA
1667        // still looks acceptable -- which is the whole point of detecting a
1668        // collapse early, since the ETA is computed from a rate estimate that
1669        // the collapse has not yet dragged down.
1670        let mut victim: Option<(usize, crate::detect::Health, f64)> = None;
1671        let mut taker: Option<(usize, f64)> = None;
1672        // A dormant connection — idle, and not already counted in `admitted` —
1673        // may only become a taker if the budget has room for it: as taker,
1674        // admitting one would open a socket the concurrency ramp, or a refusal
1675        // that has capped the transfer, has not justified — quietly defeating the
1676        // limit through the repair path. An already-busy connection spends no new
1677        // budget by taking on queued work, so it is never gated on room. Index
1678        // plays no part: the budget is a count (`admitted`), not a privilege
1679        // attached to low indices — see `admitted` for why that distinction is
1680        // the fix, not decoration.
1681        let room = self.admitted() < self.active_limit;
1682        for j in 0..self.conns.len() {
1683            let c = &self.conns[j];
1684            if now < c.setup_end || !self.sources[c.source].usable_at(now) {
1685                continue;
1686            }
1687            let e = c.eta();
1688            let h = if self.health_ranking {
1689                c.detector.health()
1690            } else {
1691                crate::detect::Health::Healthy
1692            };
1693            // Neither role may be filled by a connection that has not been
1694            // MEASURED yet, and a rate is not a measurement until it has been
1695            // sampled a few times.
1696            //
1697            // Traced on a uniform 4-connection transfer with a 100 ms round trip:
1698            // 1109 repair decisions were evaluated, almost all in the first
1699            // 0.6 s, and the ones that executed had a victim reporting 0.2 MB/s
1700            // with a projected 1092 s to finish, against a taker whose first
1701            // sample happened to be larger. Both numbers were slow-start
1702            // artefacts a second away from 70 MB/s, the "gain" was hundreds of
1703            // seconds that did not exist, and the repair moved half of a range
1704            // that would have arrived on its own — costing a fresh request on
1705            // each side. The imbalance that left behind is what the endgame
1706            // repairs were then correcting. Four requests would have done;
1707            // there were 13 to 19, and the transfer ran 22% behind `aria2c`.
1708            //
1709            // A guard on the VICTIM alone was tried first and made things worse
1710            // (repairs 2-10 became 9-15), because the taker was just as unmeasured
1711            // and the choice merely shifted. Both sides have to be warm. The one
1712            // exception is a victim the detector has graded as collapsing: that
1713            // grade is itself a measurement, and pre-empting a collapse before the
1714            // stall timeout is what the detector is for.
1715            let warm = c.detector.samples() >= REPAIR_WARM_SAMPLES;
1716            // A rate that is still climbing is not a measurement of how slow the
1717            // connection is, it is a measurement of how far into slow start it
1718            // has got — see `CollapseDetector::rising`. Warm-up in samples cannot
1719            // catch this, because slow start lasts a number of ROUND TRIPS and
1720            // the sample window is fixed: on a 100 ms path the first repair fired
1721            // at t=1.4 s on a 3:1 ratio that was 1:1 by t=2.0 s, and moved 256 MB.
1722            let settled = warm && !c.detector.rising();
1723            // As victim a connection needs no room check: it already holds a
1724            // range, so it is not being newly admitted, wherever its index falls.
1725            if c.busy()
1726                && (settled || h.is_suspect_or_worse())
1727                && victim.map(|(_, vh, ve)| (h, e) > (vh, ve)).unwrap_or(true)
1728            {
1729                victim = Some((j, h, e));
1730            }
1731            // A degraded connection must never be chosen as the TAKER: handing
1732            // work to a collapsing connection is the failure mode this whole
1733            // mechanism exists to prevent. A busy taker must be warm for the same
1734            // reason as the victim; a dormant one has no rate at all and the
1735            // profitability test below refuses it on its own.
1736            if !h.is_suspect_or_worse()
1737                && ((c.busy() && warm) || (!c.busy() && room))
1738                && taker.map(|(_, te)| e < te).unwrap_or(true)
1739            {
1740                taker = Some((j, e));
1741            }
1742        }
1743        let (vi, _, _) = victim?;
1744        let (ti, _) = taker?;
1745        if vi == ti {
1746            return None;
1747        }
1748        Some((vi, ti))
1749    }
1750
1751    fn worst_busy(&self, exclude: usize) -> Option<usize> {
1752        let mut best: Option<(usize, u64)> = None;
1753        for j in 0..self.conns.len() {
1754            if j == exclude {
1755                continue;
1756            }
1757            let c = &self.conns[j];
1758            if !c.busy() {
1759                continue;
1760            }
1761            let left = c.range.unwrap().hi.saturating_sub(c.pos);
1762            if best.map(|(_, bl)| left > bl).unwrap_or(true) {
1763                best = Some((j, left));
1764            }
1765        }
1766        best.map(|(j, _)| j)
1767    }
1768}
1769
1770/// Greedy concurrency allocation across multiple sources.
1771pub fn greedy_concurrency(
1772    rho: &[f64],
1773    gamma: &[f64],
1774    access_cap: f64,
1775    budget: usize,
1776) -> Vec<usize> {
1777    let m = rho.len();
1778    let mut n = vec![0usize; m];
1779    let g = |n: &[usize]| -> f64 {
1780        let sum: f64 = (0..m).map(|i| rho[i].min(n[i] as f64 * gamma[i])).sum();
1781        sum.min(access_cap)
1782    };
1783    let mut cur = g(&n);
1784    for _ in 0..budget {
1785        let mut best = (0usize, 0.0f64);
1786        for i in 0..m {
1787            n[i] += 1;
1788            let gain = g(&n) - cur;
1789            n[i] -= 1;
1790            if gain > best.1 {
1791                best = (i, gain);
1792            }
1793        }
1794        if best.1 <= 0.0 {
1795            break; // saturated: further connections are pure cost
1796        }
1797        n[best.0] += 1;
1798        cur += best.1;
1799    }
1800    n
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806
1807    fn src(gamma: f64) -> Source {
1808        Source {
1809            gamma_est: gamma,
1810            delta_est: 0.05,
1811            ..Default::default()
1812        }
1813    }
1814
1815    /// An arrival from a request that has already been superseded must not be
1816    /// credited against the request that replaced it.
1817    ///
1818    /// The bytes are real and on disk, so crediting them looks harmless — but it
1819    /// advances the cursor past where the NEW request starts reading, and every
1820    /// arrival from that request then fails the `off == pos` test and is
1821    /// discarded. The connection delivers bytes the scheduler never counts, so it
1822    /// reads as silent and is only rescued by the stall timeout, seconds later.
1823    /// That is the same dead air the transport's error handling exists to remove,
1824    /// reintroduced through the arrival path.
1825    #[test]
1826    fn a_late_arrival_from_a_superseded_request_is_not_credited() {
1827        let mut s = Scheduler::new(1000, vec![src(1.0)], &[1]);
1828        s.tick(0.0);
1829        assert_eq!(s.conn_range(0), Some((0, 0, 1000)));
1830        // 100 bytes land and are credited.
1831        s.on_bytes_at(0, 0, 100, 1.0, 0.5);
1832        assert_eq!(s.bytes_held(), 100);
1833        // The connection is reclaimed and re-requested from where it got to.
1834        s.on_conn_error(0, 9.0, 0.0);
1835        let acts = s.tick(10.0);
1836        assert!(
1837            matches!(acts.as_slice(), [Action::Request { conn: 0, range }] if range.lo == 100),
1838            "the reclaimed remainder must be re-requested from 100: {acts:?}"
1839        );
1840        // Now the aborted request's last write arrives, timestamped BEFORE the new
1841        // request was issued.
1842        s.on_bytes_at(0, 100, 50, 9.5, 0.1);
1843        assert_eq!(
1844            s.bytes_held(),
1845            100,
1846            "an arrival older than the request in flight was credited to it"
1847        );
1848        // And the new request's own first arrival, at the same offset, must land.
1849        s.on_bytes_at(0, 100, 50, 10.2, 0.1);
1850        assert_eq!(
1851            s.bytes_held(),
1852            150,
1853            "the live request's arrival was discarded as stale"
1854        );
1855    }
1856
1857    #[test]
1858    fn initial_split_covers_exactly() {
1859        let mut s = Scheduler::new(1000, vec![src(1.0), src(1.0)], &[1, 1]);
1860        let acts = s.tick(0.0);
1861        assert_eq!(acts.len(), 2);
1862        assert!(s.coverage_holds());
1863        assert!(s.unassigned.is_empty());
1864    }
1865
1866    #[test]
1867    fn coverage_and_liveness_hold_through_a_transfer() {
1868        let mut s = Scheduler::new(1_000_000, vec![src(1e5), src(5e4)], &[2, 2]);
1869        let mut now = 0.0;
1870        for _ in 0..4000 {
1871            s.tick(now);
1872            for j in 0..s.n_conns() {
1873                s.on_bytes(j, 500, now, 0.01);
1874            }
1875            assert!(s.coverage_holds(), "coverage broke at t={now}");
1876            assert!(s.liveness_holds(), "stuck at t={now}");
1877            now += 0.01;
1878            if s.is_complete() {
1879                break;
1880            }
1881        }
1882        assert!(
1883            s.is_complete(),
1884            "did not finish: {} / {}",
1885            s.bytes_held(),
1886            1_000_000
1887        );
1888    }
1889
1890    #[test]
1891    fn fully_stolen_range_does_not_livelock() {
1892        // Regression: a connection whose active range is stolen down to its
1893        // current position goes idle WITHOUT completing. If the queue-start
1894        // path is missing, its queued bytes are never requested.
1895        let mut s = Scheduler::new(200_000, vec![src(1e5), src(1e5)], &[1, 1]);
1896        s.tick(0.0);
1897        // conn 0 makes progress, conn 1 stalls entirely
1898        let mut now = 0.06;
1899        for _ in 0..50 {
1900            s.on_bytes(0, 1000, now, 0.01);
1901            now += 0.01;
1902            s.tick(now);
1903        }
1904        // force a steal by making conn 1 look terrible, then run to completion
1905        for _ in 0..20000 {
1906            s.tick(now);
1907            s.on_bytes(0, 1000, now, 0.01);
1908            now += 0.01;
1909            assert!(s.liveness_holds(), "livelocked at t={now}");
1910            if s.is_complete() {
1911                break;
1912            }
1913        }
1914        assert!(s.is_complete());
1915    }
1916
1917    /// Feed `conn` a window of arrivals at `rate` bytes/s ending at `now`.
1918    fn deliver(s: &mut Scheduler, conn: usize, pos: &mut u64, rate: f64, now: f64, dt: f64) {
1919        let n = (rate * dt) as u64;
1920        s.on_bytes_at(conn, *pos, n, now, dt);
1921        *pos += n;
1922    }
1923
1924    /// A connection whose rate is still climbing is in slow start, not slow, and
1925    /// repair must leave it alone.
1926    ///
1927    /// Traced on a 100 ms path: one flow at 16 MB/s against its twin at 49 had
1928    /// 256 MB taken from it at t=1.4 s, and both were at 90 MB/s by t=2.0 s. The
1929    /// cascade of repairs undoing that is what put `-x 2` at 7-8 s against
1930    /// `aria2c`'s 5.9 s on a path where two plain ranges finish together.
1931    #[test]
1932    fn a_victim_still_in_slow_start_is_not_robbed() {
1933        const SIZE: u64 = 1_000_000_000;
1934        let mut s = Scheduler::new(SIZE, vec![src(9e7)], &[2]).with_stall_timeout(30.0);
1935        let acts = s.tick(0.0);
1936        let (lo0, lo1) = (acts_range(&acts, 0).0, acts_range(&acts, 1).0);
1937        let (mut p0, mut p1) = (lo0, lo1);
1938        let mut shrinks = 0;
1939        let mut t = 0.0;
1940        // Conn 0 is settled at 90 MB/s. Conn 1 doubles every 0.4 s from 8 MB/s:
1941        // a textbook slow start that reaches its twin's rate at about t=2.
1942        while t < 3.0 {
1943            t += 0.1;
1944            let r1 = (8e6 * 2f64.powf(t / 0.4)).min(9e7);
1945            deliver(&mut s, 0, &mut p0, 9e7, t, 0.1);
1946            deliver(&mut s, 1, &mut p1, r1, t, 0.1);
1947            shrinks += s
1948                .tick(t)
1949                .iter()
1950                .filter(|a| matches!(a, Action::Shrink { .. }))
1951                .count();
1952        }
1953        assert_eq!(
1954            shrinks, 0,
1955            "a connection whose rate was still climbing was repaired against"
1956        );
1957        assert!(s.coverage_holds());
1958    }
1959
1960    /// The other direction still works: a connection that has SETTLED at a
1961    /// fraction of its peer's rate is a real laggard, and repair must move work
1962    /// off it once its rate is established.
1963    #[test]
1964    fn a_settled_laggard_is_still_repaired() {
1965        const SIZE: u64 = 1_000_000_000;
1966        let mut s = Scheduler::new(SIZE, vec![src(9e7)], &[2]).with_stall_timeout(30.0);
1967        let acts = s.tick(0.0);
1968        let (lo0, lo1) = (acts_range(&acts, 0).0, acts_range(&acts, 1).0);
1969        let (mut p0, mut p1) = (lo0, lo1);
1970        let mut shrinks = 0;
1971        let mut t = 0.0;
1972        // Conn 1 is flat at 30 MB/s from its first sample: no climb to wait out.
1973        while t < 4.0 && shrinks == 0 {
1974            t += 0.1;
1975            deliver(&mut s, 0, &mut p0, 9e7, t, 0.1);
1976            deliver(&mut s, 1, &mut p1, 3e7, t, 0.1);
1977            shrinks += s
1978                .tick(t)
1979                .iter()
1980                .filter(|a| matches!(a, Action::Shrink { .. }))
1981                .count();
1982        }
1983        assert!(
1984            shrinks > 0,
1985            "a connection settled at a third of its peer's rate was never repaired"
1986        );
1987        assert!(s.coverage_holds());
1988    }
1989
1990    fn acts_range(acts: &[Action], conn: usize) -> (u64, u64) {
1991        acts.iter()
1992            .find_map(|a| match a {
1993                Action::Request { conn: c, range } if *c == conn => Some((range.lo, range.hi)),
1994                _ => None,
1995            })
1996            .expect("initial split requests every connection")
1997    }
1998
1999    #[test]
2000    fn stall_reclaim_returns_bytes() {
2001        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
2002        s.tick(0.0);
2003        let before = s.stats.reclaims;
2004        // no bytes at all: both connections must be reclaimed after the timeout
2005        let acts = s.tick(5.0);
2006        assert!(s.stats.reclaims > before);
2007        assert!(acts.iter().any(|a| matches!(a, Action::Cancel { .. })));
2008        assert!(s.coverage_holds());
2009        assert!(s.liveness_holds());
2010    }
2011
2012    #[test]
2013    fn suspend_source_reclaims_and_reassigns() {
2014        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
2015        s.tick(0.0);
2016        s.suspend_source(0, 10.0);
2017        // Reclaimed bytes are now unassigned. They are NOT reassigned instantly:
2018        // the surviving connection is still streaming its own range, and taking
2019        // work from it would violate nothing but achieve nothing either. Work
2020        // conservation only requires that no connection sit IDLE while work
2021        // remains -- so the reassignment happens when conn 1 next goes idle.
2022        assert!(s.coverage_holds());
2023        assert!(s.unassigned.total() > 0);
2024
2025        let mut now = 0.2;
2026        let mut served_by_1 = false;
2027        for _ in 0..20_000 {
2028            let acts = s.tick(now);
2029            if acts
2030                .iter()
2031                .any(|a| matches!(a, Action::Request { conn, .. } if s.conns[*conn].source == 1))
2032            {
2033                served_by_1 = true;
2034            }
2035            s.on_bytes(1, 1000, now, 0.01);
2036            now += 0.01;
2037            assert!(s.coverage_holds());
2038            assert!(s.liveness_holds());
2039            if s.is_complete() {
2040                break;
2041            }
2042        }
2043        assert!(
2044            served_by_1,
2045            "surviving source never picked up the reclaimed work"
2046        );
2047        assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
2048    }
2049
2050    #[test]
2051    fn a_retired_source_never_gets_work_again_and_its_bytes_are_not_stranded() {
2052        // Suspension is a pause; retirement is not. A host that no longer
2053        // resolves does not get better on a timer, and retrying it costs a full
2054        // setup every backoff for the rest of the download.
2055        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
2056        s.tick(0.0);
2057        let freed = s.retire_source(0);
2058        assert_eq!(freed, vec![0], "the dead source's connections come back");
2059        assert_eq!(s.live_sources(), 1);
2060        assert!(!s.source_is_live(0));
2061        assert!(s.coverage_holds(), "reclaimed bytes must be accounted for");
2062
2063        let mut now = 0.2;
2064        for _ in 0..20_000 {
2065            let acts = s.tick(now);
2066            assert!(
2067                !acts.iter().any(
2068                    |a| matches!(a, Action::Request { conn, .. } if s.conns[*conn].source == 0)
2069                ),
2070                "a retired source was handed work at t={now}"
2071            );
2072            s.on_bytes(1, 1000, now, 0.01);
2073            now += 0.01;
2074            assert!(s.coverage_holds());
2075            assert!(s.liveness_holds());
2076            if s.is_complete() {
2077                break;
2078            }
2079        }
2080        // The whole object still arrives: retirement reclaims, it does not strand.
2081        assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
2082        // Retiring twice is a no-op rather than a second reclaim.
2083        assert!(s.retire_source(0).is_empty());
2084    }
2085
2086    #[test]
2087    fn a_retired_source_is_not_reported_as_a_planned_pause() {
2088        // `all_sources_suspended_until` tells the transport that silence is
2089        // deliberate. A retired source has no return time, and reporting its
2090        // never-updated `suspended_until` of 0.0 would answer "everything is
2091        // available now" while nothing is.
2092        let mut s = Scheduler::new(1000, vec![src(1e5), src(1e5)], &[1, 1]);
2093        s.tick(0.0);
2094        s.retire_source(0);
2095        s.suspend_source(1, 10.0);
2096        assert_eq!(
2097            s.all_sources_suspended_until(1.0),
2098            Some(10.0),
2099            "the only LIVE source's return time is the answer"
2100        );
2101        // With every live source gone there is no return time at all.
2102        s.retire_source(1);
2103        assert_eq!(s.live_sources(), 0);
2104        assert_eq!(s.all_sources_suspended_until(1.0), None);
2105    }
2106
2107    #[test]
2108    fn a_replacement_mirror_reuses_the_connections_the_dead_one_held() {
2109        // The reserve bench: a mirror list names far more sources than
2110        // politeness authorises sockets for. Substituting in place is what keeps
2111        // the aggregate socket count equal to what was authorised.
2112        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[2, 2]);
2113        s.tick(0.0);
2114        let before = s.n_conns();
2115        let moved = s.replace_source(
2116            0,
2117            Source {
2118                priority: 3,
2119                ..src(2e5)
2120            },
2121        );
2122        assert_eq!(moved, vec![0, 1], "the same connection indices, relabelled");
2123        assert_eq!(s.n_conns(), before, "no new sockets are authorised");
2124        assert_eq!(s.n_sources(), 2);
2125        assert!(s.source_is_live(0));
2126        assert_eq!(s.source_priority(0), 3);
2127        assert!(s.coverage_holds());
2128
2129        // The replacement is not graded by the dead host's failure: a connection
2130        // inheriting a Suspect grade could never be chosen as a repair taker.
2131        for j in moved {
2132            assert_eq!(s.conn_health(j), crate::detect::Health::Healthy);
2133            assert_eq!(s.conn_rate(j), 0.0);
2134        }
2135
2136        let mut now = 0.2;
2137        for _ in 0..20_000 {
2138            s.tick(now);
2139            for j in 0..s.n_conns() {
2140                s.on_bytes(j, 500, now, 0.01);
2141            }
2142            now += 0.01;
2143            assert!(s.coverage_holds());
2144            if s.is_complete() {
2145                break;
2146            }
2147        }
2148        assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
2149    }
2150
2151    #[test]
2152    fn a_stated_priority_biases_the_first_split_and_nothing_after_it() {
2153        // The prior is worth having because the first split has to be made from
2154        // something. It is worth having ONLY as a prior: a ranking cannot know
2155        // the preferred mirror is overloaded, and defending it against evidence
2156        // would be strictly worse than having no ranking at all.
2157        let ranked = |p: u32| Source {
2158            priority: p,
2159            ..src(1e5)
2160        };
2161        let mut s = Scheduler::new(120_000, vec![ranked(1), ranked(4)], &[1, 1]);
2162        let acts = s.tick(0.0);
2163        let mut got = [0u64; 2];
2164        for a in &acts {
2165            if let Action::Request { conn, range } = a {
2166                got[s.conn_source(*conn)] += range.hi - range.lo;
2167            }
2168        }
2169        assert!(
2170            got[0] > got[1],
2171            "rank 1 must open with more than rank 4: {got:?}"
2172        );
2173
2174        // Unranked sources split as they did before priorities existed, so
2175        // nothing changes for a caller that states none.
2176        let mut flat = Scheduler::new(120_000, vec![src(1e5), src(1e5)], &[1, 1]);
2177        let acts = flat.tick(0.0);
2178        let mut even = [0u64; 2];
2179        for a in &acts {
2180            if let Action::Request { conn, range } = a {
2181                even[flat.conn_source(*conn)] += range.hi - range.lo;
2182            }
2183        }
2184        assert_eq!(even[0], even[1], "no ranking must mean no bias: {even:?}");
2185
2186        // And the prior does not survive contact with measurement: give the
2187        // top-ranked source nothing and the transfer still finishes off the
2188        // other one.
2189        let mut now = 0.2;
2190        for _ in 0..40_000 {
2191            s.tick(now);
2192            s.on_bytes(1, 400, now, 0.01);
2193            now += 0.01;
2194            assert!(s.coverage_holds());
2195            if s.is_complete() {
2196                break;
2197            }
2198        }
2199        assert!(
2200            s.is_complete(),
2201            "a silent top-ranked mirror must not hold the transfer: held {}",
2202            s.bytes_held()
2203        );
2204    }
2205
2206    #[test]
2207    fn greedy_matches_exhaustive_small() {
2208        // rho/gamma chosen so the optimum is interior
2209        let rho = [2.2e6, 1.1e6, 0.7e6];
2210        let gam = [0.55e6, 0.45e6, 0.35e6];
2211        let cap = 5.0e6;
2212        for budget in 1..10usize {
2213            let n = greedy_concurrency(&rho, &gam, cap, budget);
2214            let g = |n: &[usize]| -> f64 {
2215                let s: f64 = (0..3).map(|i| rho[i].min(n[i] as f64 * gam[i])).sum();
2216                s.min(cap)
2217            };
2218            let mut best = 0.0f64;
2219            for a in 0..=budget {
2220                for b in 0..=budget {
2221                    for c in 0..=budget {
2222                        if a + b + c <= budget {
2223                            best = best.max(g(&[a, b, c]));
2224                        }
2225                    }
2226                }
2227            }
2228            assert!(
2229                (g(&n) - best).abs() < 1.0,
2230                "budget {budget}: greedy {} vs {}",
2231                g(&n),
2232                best
2233            );
2234        }
2235    }
2236
2237    #[test]
2238    fn saturation_stops_allocation() {
2239        // one source, rho = 2*gamma: two connections saturate it
2240        let n = greedy_concurrency(&[2.0e6], &[1.0e6], 1e9, 10);
2241        assert_eq!(
2242            n[0], 2,
2243            "allocated {n:?}, expected exactly the saturation point"
2244        );
2245    }
2246    /// The detector must make the SCHEDULER act sooner, not merely grade sooner.
2247    ///
2248    /// A connection collapsing to 3% of its rate must be chosen as a repair
2249    /// victim well before the stall timeout would have reclaimed it. Without
2250    /// health-ranked victim selection the scheduler waits for the projected ETA
2251    /// to drift, which is the fixed detection cost measured at 0.25-0.9 s.
2252    #[test]
2253    fn collapsed_connection_becomes_a_repair_victim_before_the_stall_timeout() {
2254        const S: u64 = 40_000_000;
2255        let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
2256        sc.tick(0.0);
2257        let mut now = 0.0;
2258        // Both healthy for a while.
2259        for _ in 0..12 {
2260            now += 0.1;
2261            sc.on_bytes(0, 400_000, now, 0.1);
2262            sc.on_bytes(1, 400_000, now, 0.1);
2263            sc.tick(now);
2264        }
2265        assert_eq!(sc.conn_health(0), crate::detect::Health::Healthy);
2266
2267        // Connection 0 collapses; connection 1 keeps its rate.
2268        let mut flagged_at = None;
2269        for _ in 0..8 {
2270            now += 0.1;
2271            sc.on_bytes(0, 12_000, now, 0.1);
2272            sc.on_bytes(1, 400_000, now, 0.1);
2273            sc.tick(now);
2274            if flagged_at.is_none() && sc.conn_health(0).is_suspect_or_worse() {
2275                flagged_at = Some(now);
2276            }
2277        }
2278        let t = flagged_at.expect("collapse must be graded");
2279        assert!(
2280            t < 1.2 + 10.0,
2281            "must be flagged well before the 10 s stall timeout, was {t}"
2282        );
2283        // And the healthy connection must never be the one downgraded.
2284        assert_eq!(
2285            sc.conn_health(1),
2286            crate::detect::Health::Healthy,
2287            "the connection holding its rate must stay Healthy"
2288        );
2289        assert!(sc.coverage_holds() && sc.liveness_holds());
2290    }
2291    /// The repair deadband must never fall below what a repair costs.
2292    ///
2293    /// `theta = scale*sqrt(delta*T_rem/n)` has the right shape but is unbounded
2294    /// below, and it approaches zero from two directions that both make repair a
2295    /// worse idea: `T_rem` shrinks as the transfer ends, `n` grows with
2296    /// concurrency. Measured on the shared-bottleneck harness, theta reached
2297    /// 0.061-0.081 s against a delta of 0.12 s — so the scheduler was spending a
2298    /// 0.12 s setup to recover a 0.06 s divergence, dozens of times per transfer.
2299    #[test]
2300    fn the_repair_deadband_never_drops_below_one_setup_cost() {
2301        const S: u64 = 8_000_000;
2302        const D: f64 = 0.12;
2303        let mk = |n: usize| {
2304            let sources = vec![Source {
2305                gamma_est: 1.4e6 / n as f64,
2306                delta_est: D,
2307                ..Default::default()
2308            }];
2309            Scheduler::new(S, sources, &[n])
2310        };
2311        // Sweep concurrency and progress: both drive theta down.
2312        for &n in &[1usize, 2, 4, 8, 16, 64] {
2313            let mut sc = mk(n);
2314            sc.tick(0.0);
2315            let mut now = 0.0;
2316            // Deliver most of the object, so T_rem — and with it the unfloored
2317            // band — becomes small.
2318            for _ in 0..60 {
2319                now += 0.05;
2320                for j in 0..n {
2321                    if sc.conn_range(j).is_some() {
2322                        sc.on_bytes(j, 100_000 / n as u64, now, 0.05);
2323                    }
2324                }
2325                sc.tick(now);
2326                let th = sc.theta_now(now);
2327                assert!(
2328                    th >= D - 1e-12,
2329                    "theta {th} fell below delta {D} at n={n}, progress {}/{S}: \
2330                     the scheduler would pay a full setup to recover a smaller divergence",
2331                    sc.bytes_held()
2332                );
2333            }
2334        }
2335    }
2336
2337    /// A stable unequal split settles after ONE equalisation; a collapse still
2338    /// gets answered.
2339    ///
2340    /// These two assertions are one test on purpose. Suppressing spurious repair is
2341    /// trivial in isolation — never repair — and that would be a regression, not a
2342    /// fix: the mechanism exists for the mirror that dies mid-transfer. The
2343    /// property worth pinning is the DISCRIMINATION between the two cases.
2344    ///
2345    /// # What this test does NOT cover
2346    ///
2347    /// It does not reproduce the repair storm, and no test in this crate can. The
2348    /// storm was a feedback loop between the scheduler and the transport: a repair
2349    /// shrank the victim's range, the victim's socket kept streaming the span
2350    /// anyway, the duplicate traffic slowed the honest connections, and that
2351    /// slowdown re-diverged the finish times into another repair. The core cannot
2352    /// see any of that — it has no sockets — so it cannot close the loop. Feeding
2353    /// it a stable unequal split, as here, correctly produces exactly one repair
2354    /// (equalising a persistent 60/40 asymmetry IS profitable) and then stops.
2355    ///
2356    /// The loop itself is tested where it lives, against a served-byte count at the
2357    /// origin: `hydra-net/tests/shrink_e2e.rs`.
2358    #[test]
2359    fn a_stable_unequal_split_settles_and_a_collapse_is_still_answered() {
2360        const S: u64 = 40_000_000;
2361        let src4 = || Source {
2362            gamma_est: 2e6,
2363            delta_est: 0.12,
2364            ..Default::default()
2365        };
2366
2367        // --- stationary: two connections at persistently unequal but stable shares.
2368        // This is what flows sharing one bottleneck look like (share ~ 1/RTT), and
2369        // no repair can change it — the asymmetry is a property of the path.
2370        let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
2371        sc.tick(0.0);
2372        let mut now = 0.0;
2373        for k in 0..60 {
2374            now += 0.1;
2375            // 60/40 split, with a little jitter, conserving the aggregate.
2376            let wobble = if k % 3 == 0 { 12_000 } else { -8_000 };
2377            sc.on_bytes(0, (240_000i64 + wobble) as u64, now, 0.1);
2378            sc.on_bytes(1, (160_000i64 - wobble) as u64, now, 0.1);
2379            sc.tick(now);
2380        }
2381        // One equalisation is correct here and the scheduler must then SETTLE: the
2382        // 60/40 share ratio is a property of the path, so re-equalising cannot
2383        // improve it and every further repair is a pure setup cost. 60 ticks over
2384        // 6 s of simulated transfer would be ample room for a storm.
2385        let stationary_repairs = sc.stats.repairs;
2386        assert!(
2387            stationary_repairs <= 1,
2388            "a stable unequal split provoked {stationary_repairs} repairs over 60 \
2389             ticks; one equalisation is profitable, repeated ones only pay setups"
2390        );
2391
2392        // --- collapse: connection 0 drops to 2% and stays there.
2393        let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
2394        sc.tick(0.0);
2395        let mut now = 0.0;
2396        for _ in 0..20 {
2397            now += 0.1;
2398            sc.on_bytes(0, 200_000, now, 0.1);
2399            sc.on_bytes(1, 200_000, now, 0.1);
2400            sc.tick(now);
2401        }
2402        let before = sc.stats.repairs;
2403        for _ in 0..40 {
2404            now += 0.1;
2405            sc.on_bytes(0, 4_000, now, 0.1);
2406            sc.on_bytes(1, 200_000, now, 0.1);
2407            sc.tick(now);
2408        }
2409        assert!(
2410            sc.stats.repairs > before,
2411            "a connection collapsing to 2% of its rate produced no repair: the \
2412             profitability test is suppressing the case repair exists for"
2413        );
2414        assert!(sc.coverage_holds() && sc.liveness_holds());
2415    }
2416
2417    /// A sole source must never be suspended past a caller's patience.
2418    ///
2419    /// Exponential backoff is right when work can go somewhere else. With one source
2420    /// it is a self-inflicted outage: nothing can move until the suspension expires,
2421    /// and a transport whose watchdog fails on silence cannot distinguish that from
2422    /// the source being gone.
2423    ///
2424    /// The numbers that made this real: `stall_timeout` 4.0s gives the transport a
2425    /// no-progress deadline of `4 * (4.0 + delta)` = 16.2s, while five consecutive
2426    /// stalls suspended the sole source for `min(4.0 * 2^3, 30)` = 30s. Measured on a
2427    /// 121.7 MiB GitHub release asset, 4 of 8 multi-connection runs aborted with a
2428    /// digest mismatch — three holding 126.9-127.0 MB of 127.6 MB, killed during a
2429    /// deliberate backoff over the final half-megabyte.
2430    #[test]
2431    fn a_sole_source_is_never_suspended_longer_than_its_stall_timeout() {
2432        const S: u64 = 8_000_000;
2433        let st = 4.0;
2434        let mut sc = Scheduler::new(S, vec![src(4e6)], &[4]).with_stall_timeout(st);
2435        sc.tick(0.0);
2436
2437        // Drive it through many consecutive stalls, which is what escalates backoff.
2438        let mut now = 0.0;
2439        let mut worst_suspension = 0.0f64;
2440        for _ in 0..12 {
2441            now += st * 1.5;
2442            sc.tick(now);
2443            if let Some(until) = sc.all_sources_suspended_until(now) {
2444                worst_suspension = worst_suspension.max(until - now);
2445            }
2446        }
2447        assert!(
2448            worst_suspension <= st.max(1.0) + 1e-9,
2449            "sole source suspended for {worst_suspension:.1}s against a {st:.1}s stall \
2450             timeout: a caller's no-progress watchdog will kill the transfer during a \
2451             pause the scheduler chose"
2452        );
2453    }
2454
2455    /// Ramping concurrency must find work WAITING, not have to steal it.
2456    ///
2457    /// With the ramp enabled the transfer starts with one connection active. If the
2458    /// initial split gives that connection the whole object, every connection
2459    /// admitted afterwards finds the unassigned set empty and its only route to
2460    /// work is a steal — paying a repair to undo a split that should not have been
2461    /// made. Measured cost of that mistake on a live 3.15 MB transfer: ~21 s
2462    /// against 6.3 s for fixed concurrency, with several runs reported as failures
2463    /// despite delivering byte-exact files.
2464    ///
2465    /// The invariant: while the active limit is below the connection budget, some
2466    /// work stays unassigned, and raising the limit produces `Request` actions
2467    /// rather than repairs.
2468    #[test]
2469    fn a_ramping_transfer_finds_unassigned_work_instead_of_stealing() {
2470        const S: u64 = 40_000_000;
2471        let sources = vec![Source {
2472            gamma_est: 2e6,
2473            delta_est: 0.05,
2474            ..Default::default()
2475        }];
2476        let mut sc = Scheduler::new(S, sources, &[8]).with_active_limit(1);
2477        let acts = sc.tick(0.0);
2478        assert_eq!(
2479            acts.iter()
2480                .filter(|a| matches!(a, Action::Request { .. }))
2481                .count(),
2482            1,
2483            "only the active connection may be given work"
2484        );
2485        assert!(
2486            !sc.unassigned_is_empty(),
2487            "the whole object was handed to one connection: connections admitted \
2488             later can only steal, which costs a repair each"
2489        );
2490
2491        // Deliver some bytes, then admit more connections as the ramp would.
2492        let mut now = 0.0;
2493        for _ in 0..5 {
2494            now += 0.1;
2495            sc.on_bytes(0, 200_000, now, 0.1);
2496            sc.tick(now);
2497        }
2498        let repairs_before = sc.stats.repairs;
2499        sc.set_active_limit(4);
2500        now += 0.1;
2501        let acts = sc.tick(now);
2502        let reqs = acts
2503            .iter()
2504            .filter(|a| matches!(a, Action::Request { .. }))
2505            .count();
2506        assert!(
2507            reqs >= 3,
2508            "admitting 3 connections produced {reqs} requests: they are not being \
2509             given the reserved work"
2510        );
2511        assert_eq!(
2512            sc.stats.repairs, repairs_before,
2513            "admitting a connection must not cost a repair"
2514        );
2515        assert!(sc.coverage_holds() && sc.liveness_holds());
2516    }
2517
2518    /// The reserve must survive the ramping connection RUNNING OUT OF WORK.
2519    ///
2520    /// [`a_ramping_transfer_finds_unassigned_work_instead_of_stealing`] pins the
2521    /// reserve as `initial_split` leaves it, and never lets the one active
2522    /// connection finish what it was given. That is the easy half. The half that
2523    /// actually decides whether a ramped transfer is fast is what work-conserving
2524    /// assignment hands out on the tick AFTER the active connection drains its
2525    /// quota — which, with the ramp still at one connection, is the common case on
2526    /// any object larger than a few of those quotas.
2527    ///
2528    /// Getting it wrong looks exactly like never having reserved at all: the idle
2529    /// connection takes `u64::MAX`, the reserve `initial_split` carefully held back
2530    /// is swallowed whole, and every connection the ramp admits afterwards finds
2531    /// nothing unassigned and must steal — the ~21 s against 6.3 s regression the
2532    /// sibling test above quotes, arrived at one tick later.
2533    ///
2534    /// The invariant, stated where it belongs: while the ramp can still grow, no
2535    /// single assignment may consume the reserve, whoever asks and whenever.
2536    #[test]
2537    fn a_ramping_connection_that_drains_its_quota_does_not_swallow_the_reserve() {
2538        const S: u64 = 40_000_000;
2539        let sources = vec![Source {
2540            gamma_est: 2e6,
2541            delta_est: 0.05,
2542            ..Default::default()
2543        }];
2544        let mut sc = Scheduler::new(S, sources, &[8]).with_active_limit(1);
2545        sc.tick(0.0);
2546        let (lo, _, hi) = sc
2547            .conn_range(0)
2548            .expect("the active connection holds a range");
2549        let quota = hi - lo;
2550        assert!(
2551            quota < S,
2552            "initial_split handed the whole object to one connection"
2553        );
2554
2555        // Drain that quota completely, so the connection goes idle with the ramp
2556        // still at one and the reserve still untouched.
2557        let mut now = 0.0;
2558        now += 0.1;
2559        sc.on_bytes(0, quota, now, 0.1);
2560        assert!(
2561            sc.conn_range(0).is_none(),
2562            "the connection should have finished its range"
2563        );
2564
2565        // The tick that re-assigns it. This is the one under test.
2566        now += 0.1;
2567        sc.tick(now);
2568        assert!(
2569            !sc.unassigned_is_empty(),
2570            "the idle connection took the entire remainder while the ramp was still \
2571             at one: every connection admitted later can only steal, which costs a \
2572             repair each"
2573        );
2574
2575        // And the reserve must still be big enough to matter — not a token sliver
2576        // left by a rounding accident.
2577        let held_back = sc.unassigned_total();
2578        assert!(
2579            held_back > (S - quota) / 2,
2580            "only {held_back} of {} remaining bytes stayed unassigned: the reserve \
2581             is nominal, and connections admitted later will still have to steal",
2582            S - quota
2583        );
2584        assert!(sc.coverage_holds() && sc.liveness_holds());
2585    }
2586
2587    /// Settled concurrency must hand out MAXIMAL ranges, not shares.
2588    ///
2589    /// The reserve exists for connections that are still to be admitted. A caller
2590    /// that never opted into the ramp has none: `active_limit` is left at
2591    /// `usize::MAX` ("every connection active"), nothing is waiting in the wings,
2592    /// and holding work back only guarantees the connection that was given a
2593    /// share has to come back for the rest — a request per share, where range
2594    /// scheduling exists to make it one.
2595    ///
2596    /// This pins the sentinel specifically. Any test of the reserve condition
2597    /// written as `admitted < active_limit` is comparing against `usize::MAX` for
2598    /// this caller, is therefore always true, and silently turns every fixed
2599    /// `-x N` transfer into the share path with no test noticing — the maximal
2600    /// branch becomes unreachable outside the ramp.
2601    #[test]
2602    fn settled_concurrency_hands_out_maximal_ranges_not_shares() {
2603        const S: u64 = 40_000_000;
2604        let sources = vec![Source {
2605            gamma_est: 2e6,
2606            delta_est: 0.05,
2607            ..Default::default()
2608        }];
2609        // No `with_active_limit`: the default, fixed-concurrency caller.
2610        let mut sc = Scheduler::new(S, sources, &[4]);
2611        sc.tick(0.0);
2612        assert_eq!(
2613            sc.active_limit(),
2614            usize::MAX,
2615            "this test is about the usize::MAX sentinel; it is not being used"
2616        );
2617
2618        // Hand two connections' ranges back, so there is unassigned work and two
2619        // idle connections to give it to. Their ranges are adjacent, so they
2620        // coalesce into one block.
2621        sc.on_conn_error(2, 0.0, 0.0);
2622        sc.on_conn_error(3, 0.0, 0.0);
2623        let reserve = sc.unassigned_total();
2624        assert!(reserve > 0, "nothing was handed back");
2625
2626        let acts = sc.tick(0.1);
2627        let biggest = acts
2628            .iter()
2629            .filter_map(|a| match a {
2630                Action::Request { range, .. } => Some(range.hi - range.lo),
2631                _ => None,
2632            })
2633            .max()
2634            .expect("an idle connection must be given the returned work");
2635        assert_eq!(
2636            biggest, reserve,
2637            "the idle connection was handed {biggest} of {reserve} available bytes: \
2638             concurrency has settled and nothing is waiting to be admitted, so \
2639             holding a reserve back only buys a second request for the same work"
2640        );
2641        assert!(sc.coverage_holds() && sc.liveness_holds());
2642    }
2643
2644    /// Every range shrink must be ANNOUNCED, not just performed.
2645    ///
2646    /// Regression test for the repair storm. The scheduler used to move
2647    /// `conns[victim].range` and emit nothing, so the transport's fetch loop —
2648    /// which tests `off < hi` against the bound it captured at request time —
2649    /// went on pulling the span that had just been handed to another connection.
2650    /// Both connections then fetched the same bytes over the same bottleneck, the
2651    /// resulting slowdown read as fresh divergence, and that triggered further
2652    /// repairs: measured at 32-49 repairs on a stationary 5.3 MB transfer whose
2653    /// correct repair count is zero, for ~2.2x the fluid optimum.
2654    ///
2655    /// The invariant is therefore stronger than "a repair happened": for every
2656    /// repair counted, the victim whose far end moved must appear in a `Shrink`
2657    /// carrying the new bound. A caller cannot honour what it is not told.
2658    #[test]
2659    fn every_repair_announces_the_victims_new_far_end() {
2660        const S: u64 = 40_000_000;
2661        let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
2662        sc.tick(0.0);
2663        let mut now = 0.0;
2664        for _ in 0..12 {
2665            now += 0.1;
2666            sc.on_bytes(0, 400_000, now, 0.1);
2667            sc.on_bytes(1, 400_000, now, 0.1);
2668            sc.tick(now);
2669        }
2670
2671        // Collapse connection 0 so a divergence repair becomes correct to make.
2672        let mut shrinks: Vec<(usize, u64)> = Vec::new();
2673        let mut repairs_before = sc.stats.repairs;
2674        let mut saw_repair = false;
2675        for _ in 0..25 {
2676            now += 0.1;
2677            sc.on_bytes(0, 4_000, now, 0.1);
2678            sc.on_bytes(1, 400_000, now, 0.1);
2679            // Snapshot each victim's far end before the tick that may move it.
2680            let before: Vec<Option<u64>> = (0..sc.n_conns())
2681                .map(|j| sc.conn_range(j).map(|(_, _, hi)| hi))
2682                .collect();
2683            let acts = sc.tick(now);
2684            for a in &acts {
2685                if let Action::Shrink { conn, hi } = a {
2686                    shrinks.push((*conn, *hi));
2687                    // The announced bound must be the one actually installed, and
2688                    // it must be a genuine reduction — never a raise, which would
2689                    // hand out bytes another connection may already hold.
2690                    assert_eq!(
2691                        sc.conn_range(*conn).map(|(_, _, h)| h),
2692                        Some(*hi),
2693                        "announced bound must match the installed one"
2694                    );
2695                    if let Some(Some(b)) = before.get(*conn) {
2696                        assert!(*hi <= *b, "a shrink must lower the far end: {b} -> {hi}");
2697                    }
2698                }
2699            }
2700            if sc.stats.repairs > repairs_before {
2701                saw_repair = true;
2702                assert!(
2703                    !shrinks.is_empty(),
2704                    "a repair was counted with no Shrink announced: the victim's \
2705                     socket would keep streaming the stolen span"
2706                );
2707                repairs_before = sc.stats.repairs;
2708            }
2709        }
2710        assert!(saw_repair, "the scenario must produce at least one repair");
2711        assert!(sc.coverage_holds() && sc.liveness_holds());
2712    }
2713
2714    /// The initial split must respect `mark_done`.
2715    ///
2716    /// Regression test: an earlier version partitioned `[0, size)` arithmetically
2717    /// and never consulted the unassigned set, so `mark_done` was silently
2718    /// ignored. That broke `--range` (fetched from offset 0 instead of the
2719    /// requested interval) and `--continue` (re-fetched bytes already on disk).
2720    #[test]
2721    fn initial_split_never_requests_bytes_marked_done() {
2722        let size = 100_000u64;
2723        let mut s = Scheduler::new(size, vec![src(1e6), src(1e6)], &[1, 1]);
2724        // Range mode: only [90_000, 90_512) is wanted.
2725        s.mark_done(0, 90_000);
2726        s.mark_done(90_512, size);
2727        let acts = s.tick(0.0);
2728        assert!(
2729            !acts.is_empty(),
2730            "the wanted interval must still be requested"
2731        );
2732        for a in &acts {
2733            if let Action::Request { range, .. } = a {
2734                assert!(
2735                    range.lo >= 90_000 && range.hi <= 90_512,
2736                    "requested {range:?} outside the wanted interval"
2737                );
2738            }
2739        }
2740        assert!(s.coverage_holds());
2741    }
2742
2743    /// Overlapping `mark_done` calls must not inflate the held count.
2744    ///
2745    /// Regression test for a silent truncation. `mark_done` credited the width of
2746    /// the span it was given rather than the bytes it actually claimed, so two
2747    /// callers marking the same prefix — a `-c` resume replaying its sidecar, and
2748    /// the concurrency probe reporting the bytes it fetched, both of which start
2749    /// at offset 0 — pushed `held` past the object's real length. `is_complete()`
2750    /// tests exactly that counter, so the transfer stopped believing it was
2751    /// finished and left a zero-filled hole in the tail of a file it reported as
2752    /// a success: measured at 240 138 unwritten bytes on an 11 200 900-byte
2753    /// object whose gzip then refused to decompress.
2754    #[test]
2755    fn overlapping_mark_done_credits_each_byte_once() {
2756        let size = 100_000u64;
2757        let mut s = Scheduler::new(size, vec![src(1e6)], &[1]);
2758        s.mark_done(0, 30_000); // a resume record
2759        s.mark_done(0, 10_000); // the probe, re-reporting part of the same prefix
2760        assert_eq!(
2761            s.bytes_held(),
2762            30_000,
2763            "the overlap must be credited once, not twice"
2764        );
2765        assert!(!s.is_complete(), "70 000 bytes are still missing");
2766
2767        // Marking every byte, in overlapping pieces, is completion — and exactly
2768        // completion, never more.
2769        s.mark_done(20_000, size);
2770        s.mark_done(0, size);
2771        assert_eq!(s.bytes_held(), size);
2772        assert!(s.is_complete());
2773    }
2774
2775    /// After the probe's ranges are marked, `held_ranges` must describe them.
2776    ///
2777    /// This is what the pre-transfer checkpoint writes into the sidecar, so that a
2778    /// ^C during or shortly after the concurrency probe does not discard bytes the
2779    /// probe already fetched at true offsets. The periodic checkpoint inside the
2780    /// transfer only fires after 2 seconds, which an early interrupt beats.
2781    #[test]
2782    fn held_ranges_reports_probe_bytes_before_any_transfer() {
2783        let size = 11_200_900u64;
2784        let mut s = Scheduler::new(size, vec![Source::default()], &[1]);
2785        // Nothing fetched yet: nothing to checkpoint, and an empty record must not
2786        // be written as though it were progress.
2787        assert!(s.held_ranges().is_empty());
2788
2789        // The probe fetched a 3 MiB prefix into the real output.
2790        s.mark_done(0, 3 << 20);
2791        assert_eq!(s.held_ranges(), vec![(0, 3 << 20)]);
2792        assert_eq!(s.bytes_held(), 3 << 20);
2793
2794        // A second, disjoint probe range is reported as its own span rather than
2795        // merged into a count: a byte count cannot describe a hole, which is why
2796        // the sidecar stores ranges.
2797        s.mark_done(5 << 20, 6 << 20);
2798        assert_eq!(s.held_ranges(), vec![(0, 3 << 20), (5 << 20, 6 << 20)]);
2799
2800        // Adjacent spans DO coalesce, so the record stays compact across a long run.
2801        s.mark_done(3 << 20, 5 << 20);
2802        assert_eq!(s.held_ranges(), vec![(0, 6 << 20)]);
2803    }
2804
2805    /// Resume: bytes already on disk must never be re-requested.
2806    #[test]
2807    fn resume_does_not_refetch_held_prefix() {
2808        let size = 64_000u64;
2809        let mut s = Scheduler::new(size, vec![src(1e6)], &[2]);
2810        s.mark_done(0, 48_000); // three quarters already fetched
2811        let acts = s.tick(0.0);
2812        for a in &acts {
2813            if let Action::Request { range, .. } = a {
2814                assert!(
2815                    range.lo >= 48_000,
2816                    "re-requested a held byte at {}",
2817                    range.lo
2818                );
2819            }
2820        }
2821        assert_eq!(
2822            s.bytes_held(),
2823            48_000,
2824            "held count must include the resumed prefix"
2825        );
2826        assert!(s.coverage_holds());
2827    }
2828}