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