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