Skip to main content

hya_core/
ramp.rs

1//! In-band concurrency ramp: find the useful connection count *during* the
2//! transfer, not before it.
3//!
4//! # Why this replaces the probe
5//!
6//! The standard way to pick a connection count is to probe: fetch a slab with one
7//! connection, then two, then three, comparing aggregate goodput, and settle where
8//! the marginal gain stops paying. That is what [`crate::Admission`] does, and it
9//! is the right *decision rule*. The problem is where the samples come from.
10//!
11//! HARP (Kim, Yildirim & Kosar, SC'16) states the objection plainly: probing
12//! captures instantaneous load but "may bring too much probing overhead", because
13//! each sample is an extra transfer paid for before the real one begins. Measured
14//! on this client against a live path with a 3.15 MB object, the climbing probe
15//! made the whole transfer **1.96x slower** than not probing at all — 18.2 s
16//! median against 8.3 s, paired across 9 interleaved repetitions, p = 0.004. The
17//! search cost more than the concurrency it found could recover. HARP's own answer
18//! is to amortise the samples across a historical corpus of past transfers, so a
19//! new transfer needs at most one probe.
20//!
21//! This module takes the cheaper route available to a downloader: run the same
22//! search **on the object itself**. Concurrency is adjustable mid-transfer
23//! ([`Scheduler::set_active_limit`]), so the ramp starts at one connection,
24//! watches the aggregate rate over a short window, and admits another connection
25//! while the marginal gain justifies its setup cost. Every byte moved while
26//! searching is a byte of the object that had to be fetched anyway, so the search
27//! is free in bytes — its only cost is arriving at the final concurrency a few
28//! windows late.
29//!
30//! # What it measures, and the trap in measuring it
31//!
32//! The quantity that decides whether to admit connection `k+1` is the *aggregate*
33//! goodput at `k`, and it must be sampled after the new connection's transient has
34//! passed. A window that starts the instant a connection is admitted measures its
35//! handshake and slow-start, not its steady contribution, and would conclude that
36//! every added connection helps less than it does. So each admission is followed by
37//! a settling delay before the next window counts.
38//!
39//! The opposite error is just as easy: a window long enough to be clean is a
40//! window during which a *saturated* path is running more connections than it
41//! needs. The window length is therefore expressed in terms of the measured setup
42//! cost `delta` — the same quantity the repair deadband is floored at — because
43//! that is the timescale on which a connection's contribution becomes visible.
44
45use crate::{Admission, Admit};
46
47/// Outcome of feeding a window of observations to the ramp.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum Ramp {
50    /// Keep the current concurrency; not enough evidence yet this window.
51    Hold,
52    /// Raise the active limit to this many connections.
53    Raise(usize),
54    /// The search is finished: this is the useful count.
55    Settled(usize),
56}
57
58/// Drives concurrency upward on a live transfer while it pays to do so.
59#[derive(Clone, Debug)]
60pub struct ConcurrencyRamp {
61    adm: Admission,
62    /// Connections currently admitted.
63    level: usize,
64    /// Hard ceiling: politeness or an explicit `-x`, never exceeded.
65    max: usize,
66    /// Wall clock at which the current measurement window may begin counting.
67    /// Set past the present on each admission so a new connection's handshake and
68    /// slow-start are not charged against it.
69    window_open_at: f64,
70    /// Wall clock at which the current window closes.
71    window_ends_at: f64,
72    /// Bytes seen since the window opened.
73    bytes: u64,
74    /// When the window actually started counting bytes.
75    counting_since: f64,
76    settled: Option<usize>,
77    /// The first window's rate at the current level, awaiting a confirming second.
78    ///
79    /// Held rather than recorded so `Admission` sees exactly one sample per level; see
80    /// the confirmation block in `poll` for why one window is not enough.
81    held_rate: Option<f64>,
82    /// Connections the transport reports as actually delivering bytes.
83    ///
84    /// `None` means the caller does not supply the signal, which disables the
85    /// warm-up gate below and leaves the timing exactly as it was.
86    delivering: Option<usize>,
87    /// While set, a level has been admitted whose connections have not all
88    /// delivered a byte yet, and the measurement window is held open. The value is
89    /// the wall clock past which the ramp stops waiting and measures anyway.
90    warm_deadline: Option<f64>,
91}
92
93/// How long a measurement window runs, in multiples of the measured setup cost.
94///
95/// Long enough that a connection's steady contribution dominates its transient,
96/// short enough that a saturated path is not over-provisioned for long. Three
97/// setup costs is roughly the point at which a TCP flow's congestion window has
98/// stopped being the limiting factor on a typical path.
99/// A measurement window must outlast TCP slow start, or it measures the wrong thing.
100///
101/// # The trap this constant sits in
102///
103/// A newly admitted flow does not deliver its share immediately: it opens with a small
104/// congestion window and needs several round trips to reach steady state. Measure it
105/// before then and the observed rate is still climbing — so the ramp concludes the
106/// connection is paying its way and admits more, on every level, until it hits the
107/// ceiling. That is not a threshold that needs tuning; it is measuring a transient and
108/// calling it a steady state.
109///
110/// Both failure modes were observed on the same path, and they pull in opposite
111/// directions:
112///
113/// * Windows too LONG (tied to `delta` with no ceiling): reaching 8 connections took
114///   16–32 s on a path where `delta` was 0.5–1.0 s, longer than the whole 3.15 MB
115///   transfer. Measured 2.78x slower than a fixed `-x 8`.
116/// * Windows too SHORT (`MAX_WINDOW_S` = 0.6 s, i.e. ~3 RTTs at the 200 ms RTT of these
117///   origins, against the 4–8 RTTs slow start needs): every level looks like it is
118///   still improving, so the search runs to the ceiling. Measured settling at 8 on a
119///   path a single stream saturates, causing significant transfer slowdown.
120///
121/// No single value satisfies both, which is why the ramp no longer climbs from one. It
122/// starts at the concurrency that measured fastest in the field and only *adds* when
123/// there is direct evidence of headroom — see `ConcurrencyRamp::new`.
124const WINDOW_DELTAS: f64 = 3.0;
125
126/// Settling time after an admission before its window may count, in multiples of
127/// the setup cost. A connection that has not finished its handshake contributes
128/// nothing, and charging that silence to the aggregate would understate the gain.
129const SETTLE_DELTAS: f64 = 1.5;
130
131/// Floor on both, so a path reporting an implausibly small setup cost cannot
132/// collapse the windows to noise.
133const MIN_WINDOW_S: f64 = 0.25;
134
135/// How long the ramp waits for a newly admitted connection to deliver its first
136/// byte before measuring the level without it, in multiples of `delta`.
137///
138/// Generous relative to the windows on purpose: this is not a measurement window
139/// but a bound on patience, and the thing it waits for (connect + TLS + first byte)
140/// costs several `delta` on any path where `delta` is a single round trip.
141const WARM_DELTAS: f64 = 8.0;
142
143/// Bounds on that patience. The floor covers a fast path whose `delta` estimate is
144/// small enough that eight of them still land inside one handshake; the ceiling is
145/// what keeps a connection that never delivers from stalling the search — the
146/// scheduler's stall detection and reclaim own that connection, not the ramp.
147const MIN_WARM_S: f64 = 1.0;
148const MAX_WARM_S: f64 = 8.0;
149
150/// Ceiling on both.
151///
152/// Scaling the windows by `delta` alone has the effect exactly backwards on a slow
153/// path: a large `delta` means each window is long, so the ramp takes longest to
154/// reach useful concurrency precisely where setup costs most and concurrency is
155/// most valuable. On the measured path `delta` reached ~1.0 s, which put full
156/// concurrency 31 s away — past the end of the transfer. `delta` still sets the
157/// timescale, but it cannot set an unbounded one.
158const MAX_WINDOW_S: f64 = 0.6;
159
160impl ConcurrencyRamp {
161    /// `min_gain_frac` is the marginal goodput, as a fraction of the
162    /// single-connection rate, that a new connection must add to be kept.
163    /// Start the search at `start` connections rather than at one.
164    ///
165    /// # Why the search no longer climbs from one
166    ///
167    /// Climbing costs a measurement window per level, and a window long enough to
168    /// outlast slow start (see `WINDOW_DELTAS`) is long enough that the climb dominates
169    /// a short transfer. Climbing from one is only worth it if the levels above one are
170    /// likely to be much better — and on the paths measured, they are not.
171    ///
172    /// The asymmetry, not a claimed win, is what justifies starting low. Measured
173    /// over 20 paired repetitions on four objects, starting at 1 connection is
174    /// statistically indistinguishable from a fixed baseline while fixed `-x 8`
175    /// cost 1.37–3.04x, and on a path a single stream already saturates `-x 8`
176    /// incurred a 3.6x slowdown where `-x 1` was 1.17x. So the downside of starting
177    /// high is large and measured; the upside is not.
178    ///
179    /// Starting at one is therefore a conservative policy choice, ensuring minimal
180    /// overhead while admitting more connections only when headroom is proven.
181    pub fn starting_at(min_gain_frac: f64, start: usize, max: usize) -> Self {
182        let mut r = Self::new(min_gain_frac, max);
183        r.level = start.clamp(1, r.max);
184        r
185    }
186
187    pub fn new(min_gain_frac: f64, max: usize) -> Self {
188        Self {
189            adm: Admission::new(min_gain_frac, max),
190            level: 1,
191            max: max.max(1),
192            window_open_at: 0.0,
193            window_ends_at: 0.0,
194            bytes: 0,
195            counting_since: 0.0,
196            settled: None,
197            held_rate: None,
198            delivering: None,
199            warm_deadline: None,
200        }
201    }
202
203    /// Opt in to the warm-up gate, and arm it for the level the search starts at.
204    ///
205    /// # The measurement this exists to prevent
206    ///
207    /// The windows are scaled by `delta`, the per-REQUEST setup cost, which on a
208    /// pooled connection is one round trip and is measured at 50-100 ms on the paths
209    /// this was tuned against. Admitting a CONNECTION costs something else entirely:
210    /// a TCP handshake, a TLS handshake and a first byte, and on a 250 ms-RTT path
211    /// that is 1.2-1.6 s before a single byte arrives — longer than `SETTLE_DELTAS`
212    /// and `WINDOW_DELTAS` together, both of which are clamped at `MAX_WINDOW_S`.
213    ///
214    /// The window therefore opened and closed while the new connection was still
215    /// handshaking, the level measured as no better than the one below it, and the
216    /// search settled at ONE on a path with real headroom. Reported from the field
217    /// as "only two of eight connections start": the second connection had delivered
218    /// 240 KB — the tail of its slow start — when the ramp judged it and stopped.
219    ///
220    /// A low-RTT path escapes it by luck: there the handshake fits inside the settle
221    /// delay, so the same code measures a warm connection and climbs to the ceiling.
222    /// That is what made this look path-specific rather than systematic.
223    ///
224    /// So the settle delay cannot be a duration alone. The transport reports how many
225    /// connections are actually delivering (`note_delivering`), and the window does
226    /// not open until the level's connections are among them, or until the deadline
227    /// this arms expires — a connection that never delivers must not stall the search
228    /// forever; the scheduler's own stall detectors own that case.
229    pub fn arm_warmup(&mut self, now: f64, delta: f64) {
230        self.delivering = Some(0);
231        self.warm_deadline = Some(now + (delta * WARM_DELTAS).clamp(MIN_WARM_S, MAX_WARM_S));
232    }
233
234    /// Report how many connections are currently delivering bytes.
235    ///
236    /// Aggregate count, not a set: the gate only asks whether the level it is about
237    /// to measure is fully on the wire.
238    pub fn note_delivering(&mut self, n: usize) {
239        if self.delivering.is_some() {
240            self.delivering = Some(n);
241        }
242    }
243
244    /// Begin the first window. `now` is the transfer's clock, `delta` the measured
245    /// per-request setup cost.
246    pub fn start(&mut self, now: f64, delta: f64) {
247        let settle = (delta * SETTLE_DELTAS).clamp(MIN_WINDOW_S, MAX_WINDOW_S);
248        let window = (delta * WINDOW_DELTAS).clamp(MIN_WINDOW_S, MAX_WINDOW_S);
249        self.window_open_at = now + settle;
250        self.window_ends_at = self.window_open_at + window;
251        self.counting_since = self.window_open_at;
252        self.bytes = 0;
253    }
254
255    /// Record bytes delivered by the whole transfer.
256    ///
257    /// Aggregate, not per-connection: the question is whether the *path* is
258    /// carrying more, and a per-connection view cannot answer it — on a saturated
259    /// link each connection's own rate falls as connections are added while the
260    /// total stays flat, which is exactly the case the ramp must detect.
261    pub fn observe(&mut self, bytes: u64, now: f64) {
262        if now >= self.window_open_at {
263            self.bytes += bytes;
264        }
265    }
266
267    /// The useful count, once the search has settled.
268    pub fn settled(&self) -> Option<usize> {
269        self.settled
270    }
271
272    /// Current concurrency.
273    pub fn level(&self) -> usize {
274        self.level
275    }
276
277    /// Close the window if it is due and decide what to do next.
278    pub fn poll(&mut self, now: f64, delta: f64) -> Ramp {
279        if let Some(n) = self.settled {
280            return Ramp::Settled(n);
281        }
282        // ---- warm-up gate --------------------------------------------------
283        //
284        // Hold the window at arm's length while the level's connections are still
285        // coming up: `start` is called on every poll, so `window_ends_at` keeps
286        // moving and no window can close over a handshake. See `arm_warmup` for the
287        // measurement error this prevents.
288        if let Some(deadline) = self.warm_deadline {
289            let warm = self.delivering.unwrap_or(usize::MAX) >= self.level;
290            if warm || now >= deadline {
291                self.warm_deadline = None;
292            }
293            // Either way the windows restart from HERE: on the warm path so the
294            // first window measures a delivering level, and on the deadline path so
295            // the level is judged over a full window rather than whatever remained.
296            self.held_rate = None;
297            self.start(now, delta);
298            return Ramp::Hold;
299        }
300        if now < self.window_ends_at {
301            return Ramp::Hold;
302        }
303        let span = (now - self.counting_since).max(1e-3);
304        let rate = self.bytes as f64 / span;
305
306        // A window that saw nothing is not evidence of saturation — it is evidence
307        // of a stall, which the scheduler's own detectors handle. Re-arm rather
308        // than concluding.
309        if self.bytes == 0 {
310            self.start(now, delta);
311            return Ramp::Hold;
312        }
313
314        // Require the same evidence TWICE before raising, and average the two windows.
315        //
316        // One window can land mid-slow-start, while a newly admitted flow is still
317        // opening its congestion window: its rate is still climbing, which is
318        // indistinguishable from "this connection is paying for itself". Acting on a
319        // single window is what drove the search to the ceiling on a path one stream
320        // already saturated (settled counts [2, 8, 8, 8, 8] over five repetitions,
321        // resulting in 1.68-2.23x slower transfers).
322        //
323        // The first window at a level is held back rather than recorded, so `Admission`
324        // sees one sample per level and its per-connection gain arithmetic stays valid.
325        // The two are averaged, which also damps the window-to-window variance that
326        // made a single reading unreliable on a volatile link.
327        if let Some(first) = self.held_rate.take() {
328            // Take the SECOND window, not the average of the two.
329            //
330            // Averaging seemed conservative and is the opposite. The first window at a
331            // new level lands mid-slow-start, while the newly admitted flows are still
332            // opening their congestion windows; the second is closer to steady state.
333            // Averaging them therefore reports a number no window measured, and because
334            // the first is always the lower of the two on a warming path, the average
335            // understates the level's true rate — making the NEXT step look larger than
336            // it is and driving the search upward. Measured: the search reached 8 in 9
337            // of 12 runs on paths where one connection was 1.8-3.2x faster.
338            //
339            // The first window is not wasted: it is the settling time that makes the
340            // second one meaningful.
341            let _ = first;
342            return self.decide(rate, now, delta);
343        }
344        self.held_rate = Some(rate);
345        self.start(now, delta);
346        Ramp::Hold
347    }
348
349    /// Act on a confirmed goodput reading for the current level.
350    fn decide(&mut self, rate: f64, now: f64, delta: f64) -> Ramp {
351        // Opt-in trace: the ramp's decisions are invisible from the outside (the CLI
352        // reports only the peak level), and inferring them from wall-clock cost two
353        // wrong hypotheses. HYDRA_RAMP_TRACE=1 prints each window's verdict.
354        let trace = std::env::var_os("HYDRA_RAMP_TRACE").is_some();
355        if trace {
356            eprintln!(
357                "ramp: level={} rate={:.0} B/s at t={:.2}s delta={:.3}",
358                self.level, rate, now, delta
359            );
360        }
361        match self.adm.observe_at(self.level, rate) {
362            Admit::Stop => {
363                // `Admission` settles at the level whose marginal gain last paid,
364                // which may be below the current level: the last connection
365                // admitted did not earn its place. Settling at the smaller number
366                // is the point of the search.
367                let n = self.adm.settled().unwrap_or(self.level).clamp(1, self.max);
368                self.settled = Some(n);
369                self.level = n;
370                Ramp::Settled(n)
371            }
372            Admit::Add if self.level < self.max => {
373                // DOUBLE, do not increment.
374                //
375                // Incrementing costs one settle-plus-window per connection, so
376                // reaching 8 takes 7 windows. Measured on a live path with
377                // delta ~0.5-1.0 s that is 16-32 s of clock — longer than the whole
378                // 3.15 MB transfer, which is why the first version of this ramp was
379                // 1.74x slower than a fixed `-x 8` (p = 0.016) despite moving no
380                // wasted bytes. The search was free in bytes and ruinous in time.
381                //
382                // Doubling reaches the ceiling in log2(max) windows: 3 instead of 7
383                // for max=8. This is slow start's own argument — when the target is
384                // unknown and each probe costs a round trip, multiply. The overshoot
385                // it risks is bounded and recoverable, because `Admission` settles
386                // at the last level whose marginal gain paid, and `set_active_limit`
387                // can lower the count without cancelling anything: an over-admitted
388                // connection finishes the range it holds and then goes quiet.
389                self.level = (self.level * 2).min(self.max);
390                self.held_rate = None;
391                // Re-arm the warm-up gate for the connections this admits: they have
392                // a handshake ahead of them, and measuring them through it is what
393                // settled the search at one on high-RTT paths.
394                if self.delivering.is_some() {
395                    self.warm_deadline =
396                        Some(now + (delta * WARM_DELTAS).clamp(MIN_WARM_S, MAX_WARM_S));
397                }
398                self.start(now, delta);
399                Ramp::Raise(self.level)
400            }
401            Admit::Add => {
402                // At the ceiling: the search wanted more and is not allowed more,
403                // so it is finished at the ceiling rather than undecided.
404                self.settled = Some(self.level);
405                Ramp::Settled(self.level)
406            }
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// Drive the ramp with a synthetic path whose aggregate rate saturates at
416    /// `sat` connections, and report where it settles.
417    fn run(sat: usize, max: usize, per_conn: f64, delta: f64) -> usize {
418        let mut r = ConcurrencyRamp::new(0.15, max);
419        let mut now = 0.0;
420        r.start(now, delta);
421        for _ in 0..(max * 40) {
422            // Aggregate rate: linear in connections until saturation, flat after.
423            let rate = per_conn * r.level().min(sat) as f64;
424            // Advance in small steps, feeding bytes at that rate.
425            let step = 0.05;
426            now += step;
427            r.observe((rate * step) as u64, now);
428            if let Ramp::Settled(n) = r.poll(now, delta) {
429                return n;
430            }
431        }
432        r.level()
433    }
434
435    /// A path that saturates at one connection must not be given eight.
436    ///
437    /// This is the measured pathology: a fixed `-x 8` against a saturated access
438    /// link was 2.7x SLOWER than a single stream, because each extra connection
439    /// added a setup cost against capacity that was already committed.
440    #[test]
441    fn a_saturated_path_settles_low() {
442        let n = run(1, 8, 1.4e6, 0.12);
443        assert!(
444            n <= 2,
445            "settled at {n} connections on a path that saturates at 1; \
446             the extra connections are pure setup cost"
447        );
448    }
449
450    /// A path with real headroom must actually be used.
451    ///
452    /// The opposite failure is just as bad and much easier to ship: a ramp that
453    /// always settles at one connection would score perfectly on the test above
454    /// while throwing away the entire point of parallel range fetching.
455    #[test]
456    fn a_path_with_headroom_ramps_up() {
457        let n = run(6, 8, 400e3, 0.12);
458        assert!(
459            n >= 4,
460            "settled at {n} connections on a path that scales to 6; \
461             the ramp is leaving throughput on the table"
462        );
463    }
464
465    /// Reaching useful concurrency must cost a bounded, small amount of clock.
466    ///
467    /// This is the property whose absence made the first version of this ramp
468    /// SLOWER than fixed concurrency. The search moved no wasted bytes — every byte
469    /// was object data — and was still a net loss, because incrementing one
470    /// connection per window put full concurrency 16-32 s away on a path whose
471    /// `delta` was ~0.5-1.0 s, against a transfer that finished in 13.6 s. Measured:
472    /// 1.74x slower than `-x 8`, p = 0.016 over 7 paired reps.
473    ///
474    /// A search that is free in bytes but expensive in time is still expensive. The
475    /// bound has two parts, and both are load-bearing: doubling makes the number of
476    /// windows logarithmic in the ceiling, and clamping the window length keeps a
477    /// slow path — where `delta` is large — from stretching each one.
478    #[test]
479    fn full_concurrency_is_reached_in_bounded_time() {
480        for &delta in &[0.01f64, 0.12, 0.5, 1.0, 5.0] {
481            let mut r = ConcurrencyRamp::new(0.15, 8);
482            let mut now = 0.0;
483            r.start(now, delta);
484            let mut reached_at = None;
485            // A path with plenty of headroom, so the ramp always wants to grow.
486            while now < 30.0 {
487                now += 0.02;
488                r.observe((2e6 * r.level() as f64 * 0.02) as u64, now);
489                let out = r.poll(now, delta);
490                if r.level() >= 8 {
491                    reached_at = Some(now);
492                    break;
493                }
494                if let Ramp::Settled(_) = out {
495                    break;
496                }
497            }
498            let t = reached_at.unwrap_or(f64::INFINITY);
499            // 10 s, not 5 s. Each level now costs TWO windows rather than one, because
500            // a single window can land mid-slow-start and read a flow that is still
501            // opening its congestion window as a link with headroom — which sent the
502            // search to the ceiling on a path one stream already saturated. The bound
503            // is doubled deliberately, and it is still a bound: the point of the test is
504            // that time-to-concurrency cannot grow without limit as `delta` grows, which
505            // is the defect that made the first version of this ramp 2.78x slower than
506            // a fixed `-x 8`.
507            //
508            // Worth noting what this costs in practice: nothing on the common path,
509            // because the search now STARTS at the level the field data says wins and
510            // only spends these windows when there is headroom to find.
511            assert!(
512                t <= 10.0,
513                "took {t:.1}s to reach 8 connections at delta={delta}: a search that \
514                 costs more clock than the transfer saves is a net loss"
515            );
516        }
517    }
518
519    /// The ceiling is a hard limit, not a target to overshoot.
520    #[test]
521    fn the_ceiling_is_never_exceeded() {
522        for max in [1usize, 2, 4] {
523            let n = run(64, max, 400e3, 0.12);
524            assert!(n <= max, "settled at {n} above ceiling {max}");
525        }
526    }
527
528    /// A window that sees no bytes is a stall, not saturation.
529    ///
530    /// Concluding "saturated" from silence would settle the ramp at one connection
531    /// on any path that hiccups early — permanently, since the search does not
532    /// resume once settled.
533    #[test]
534    fn an_empty_window_does_not_settle_the_search() {
535        let mut r = ConcurrencyRamp::new(0.15, 8);
536        r.start(0.0, 0.12);
537        // Two windows' worth of clock with nothing delivered.
538        let out = r.poll(10.0, 0.12);
539        assert_eq!(out, Ramp::Hold, "silence must not be read as saturation");
540        assert!(r.settled().is_none());
541    }
542
543    /// A path where opening a connection costs `handshake` seconds before its first
544    /// byte and `slow_start` more before it runs at `per_conn`.
545    ///
546    /// The first connection is modelled as POOLED — no handshake — because that is
547    /// what the transfer sees: the probe leaves a live connection behind, so
548    /// connection 0 starts delivering at once while every connection the ramp admits
549    /// later pays the full cost. That asymmetry is the whole point: the baseline is
550    /// measured warm and the step is measured cold.
551    fn run_warming(
552        sat: usize,
553        max: usize,
554        per_conn: f64,
555        delta: f64,
556        handshake: f64,
557        slow_start: f64,
558        gate: bool,
559    ) -> (usize, f64) {
560        let mut r = ConcurrencyRamp::new(0.15, max);
561        let mut now = 0.0;
562        r.start(now, delta);
563        if gate {
564            r.arm_warmup(now, delta);
565        }
566        // Wall clock at which each connection was opened; connection 0 with the
567        // transfer, the rest when the ramp admitted them.
568        let mut opened = vec![0.0f64];
569        let mut hs = vec![0.0f64];
570        let step = 0.02;
571        while now < 60.0 {
572            now += step;
573            // Per-connection contribution: nothing through the handshake, then a
574            // linear climb to full rate over `slow_start`.
575            let mut live = 0usize;
576            let mut rate = 0.0;
577            for (i, &t0) in opened.iter().enumerate() {
578                let since = now - t0 - hs[i];
579                if since >= 0.0 {
580                    live += 1;
581                    rate += per_conn * (since / slow_start).clamp(0.0, 1.0);
582                }
583            }
584            // The path saturates at `sat` connections' worth of aggregate rate.
585            rate = rate.min(per_conn * sat as f64);
586            r.observe((rate * step) as u64, now);
587            if gate {
588                r.note_delivering(live);
589            }
590            match r.poll(now, delta) {
591                Ramp::Raise(n) => {
592                    while opened.len() < n {
593                        opened.push(now);
594                        hs.push(handshake);
595                    }
596                }
597                Ramp::Settled(n) => return (n, now),
598                Ramp::Hold => {}
599            }
600        }
601        (r.level(), now)
602    }
603
604    /// The field failure, reproduced: a high-RTT path with eight-way headroom on
605    /// which the search stops at ONE connection.
606    ///
607    /// Reported as "only two of eight connections start" on a 116 MB GitHub release
608    /// asset. The second connection had moved 240 KB — the tail of its slow start —
609    /// when the ramp judged the level and stopped, and the six above it were never
610    /// admitted at all.
611    ///
612    /// The cause is a units mismatch, not a threshold: the windows are scaled by
613    /// `delta`, the per-request cost on a POOLED connection, while what they have to
614    /// outlast is a fresh TCP plus TLS handshake. Where the handshake is longer than
615    /// settle-plus-window (both clamped at `MAX_WINDOW_S` = 0.6 s), the level is
616    /// measured entirely through the new connection's silence, reads as no better
617    /// than the level below it, and the search settles at the bottom.
618    ///
619    /// Kept as a test of the UNGATED path so the defect cannot come back silently:
620    /// if this ever settles high on its own, the gate has stopped being what fixes it
621    /// and the reason for that should be understood.
622    #[test]
623    fn a_slow_handshake_defeats_the_ungated_search() {
624        let (n, _) = run_warming(8, 8, 2.4e6, 0.4, 2.5, 2.0, false);
625        assert_eq!(
626            n, 1,
627            "expected the ungated ramp to be fooled by a 2.5 s handshake; it settled \
628             at {n}, so this test no longer covers the defect it was written for"
629        );
630    }
631
632    /// With the gate armed, the same path is used.
633    ///
634    /// The window does not open until the level's connections are delivering, so the
635    /// step is measured on what the connections carry rather than on how long they
636    /// took to open.
637    #[test]
638    fn the_warm_up_gate_finds_the_headroom_a_slow_handshake_hides() {
639        let (n, t) = run_warming(8, 8, 2.4e6, 0.4, 2.5, 2.0, true);
640        assert!(
641            n >= 4,
642            "settled at {n} on a path that scales to 8: the gate did not restore the \
643             measurement"
644        );
645        // Waiting for the handshake costs clock, and it must stay bounded: four
646        // levels at a 2.5 s handshake plus two windows each is the budget here.
647        assert!(
648            t <= 30.0,
649            "took {t:.1}s to settle at {n}: patience is not free and must be bounded"
650        );
651    }
652
653    /// A connection that never delivers must not stall the search.
654    ///
655    /// The gate waits for the wire, so a black-holed connection would hold the window
656    /// open forever without the deadline. Reclaiming that connection is the
657    /// scheduler's job, not the ramp's; the ramp's job is to stop waiting.
658    #[test]
659    fn the_gate_gives_up_on_a_connection_that_never_delivers() {
660        // A handshake longer than the whole simulation: nothing admitted after
661        // connection 0 ever produces a byte.
662        let (n, t) = run_warming(8, 8, 2.4e6, 0.4, 1e6, 2.0, true);
663        assert!(
664            n <= 2,
665            "settled at {n} on a path where only one connection ever delivered"
666        );
667        assert!(
668            t < 60.0,
669            "the search never settled: the warm-up deadline is not bounding the wait"
670        );
671    }
672}