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