Skip to main content

cf_mach/nq_load_generator/
mod.rs

1// Copyright (c) 2023-2024 Cloudflare, Inc.
2// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
3
4use std::{collections::HashMap, sync::Arc};
5
6use crate::nq_core::client::{Direction, ThroughputClient};
7use crate::nq_core::{
8    BodyEvent, ConnectionType, EstablishedConnection, InflightBody, Network, OneshotResult,
9    ScopedHeaders, Time, Timestamp, oneshot_result,
10};
11use crate::nq_stats::CounterSeries;
12use anyhow::Context;
13use http::{HeaderMap, HeaderName, HeaderValue, Uri};
14use rand::seq::SliceRandom;
15use serde::Deserialize;
16use tokio::sync::RwLock;
17use tokio::sync::mpsc;
18use tokio::sync::mpsc::UnboundedReceiver;
19use tokio::sync::mpsc::error::TryRecvError;
20use tokio_util::sync::CancellationToken;
21use tracing::Instrument;
22
23#[derive(Debug, Deserialize)]
24pub struct LoadConfig {
25    pub headers: HashMap<String, String>,
26    /// Headers attached only to requests whose host matches the scope's
27    /// allowlist.
28    #[serde(skip)]
29    pub scoped_headers: Option<ScopedHeaders>,
30    pub download_url: url::Url,
31    pub upload_url: url::Url,
32}
33
34pub struct LoadGenerator {
35    headers: HeaderMap<HeaderValue>,
36    scoped_headers: Option<ScopedHeaders>,
37    config: LoadConfig,
38    loads: Vec<LoadedConnection>,
39}
40
41impl LoadGenerator {
42    pub fn new(config: LoadConfig) -> anyhow::Result<Self> {
43        let mut headers = HeaderMap::new();
44
45        for (key, value) in config.headers.iter() {
46            headers.insert(
47                HeaderName::from_bytes(key.as_bytes())?,
48                HeaderValue::from_bytes(value.as_bytes())?,
49            );
50        }
51
52        Ok(Self {
53            headers,
54            scoped_headers: config.scoped_headers.clone(),
55            config,
56            loads: Vec::new(),
57        })
58    }
59
60    #[tracing::instrument(skip(self, network, time, shutdown))]
61    pub fn new_loaded_connection(
62        &self,
63        direction: Direction,
64        conn_type: ConnectionType,
65        network: Arc<dyn Network>,
66        time: Arc<dyn Time>,
67        shutdown: CancellationToken,
68    ) -> anyhow::Result<OneshotResult<LoadedConnection>> {
69        let (tx, rx) = oneshot_result();
70
71        let uri: Uri = match direction {
72            Direction::Up(_) => self.config.upload_url.as_str().parse()?,
73            Direction::Down => self.config.download_url.as_str().parse()?,
74        };
75
76        let client = match direction {
77            Direction::Down => ThroughputClient::download(),
78            Direction::Up(size) => ThroughputClient::upload(size),
79        };
80
81        let client = client
82            .new_connection(conn_type)
83            .headers(self.headers.clone())
84            .scoped_headers(self.scoped_headers.clone());
85
86        let response_fut = client.send(
87            uri.clone(),
88            Arc::clone(&network),
89            Arc::clone(&time),
90            shutdown.clone(),
91        )?;
92
93        tracing::debug!("got loaded connection response future");
94
95        // An upload load is an open-ended *sequence* of bounded requests rather
96        // than one request, so its events are produced by a driver task instead
97        // of coming straight off a single body. See [`UploadReissue`].
98        let reissue = match direction {
99            Direction::Up(bound) => Some(UploadReissue {
100                bound,
101                uri,
102                headers: self.headers.clone(),
103                scoped_headers: self.scoped_headers.clone(),
104                network,
105                time,
106                shutdown,
107            }),
108            Direction::Down => None,
109        };
110
111        tokio::spawn(
112            async move {
113                let inflight_body = response_fut
114                    .await
115                    .context("could not await response for loaded connection")?;
116
117                tracing::debug!("sending loaded connection");
118
119                let Some(reissue) = reissue else {
120                    let _ = tx.send(Ok(LoadedConnection {
121                        connection: inflight_body.connection,
122                        events_rx: inflight_body.events,
123                        state: LoadState::default(),
124                    }));
125
126                    return Ok(());
127                };
128
129                let (events_tx, events_rx) = mpsc::unbounded_channel();
130                let connection = Arc::clone(&inflight_body.connection);
131
132                let _ = tx.send(Ok(LoadedConnection {
133                    connection: Arc::clone(&connection),
134                    events_rx,
135                    state: LoadState::default(),
136                }));
137
138                reissue
139                    .run(connection, inflight_body.events, events_tx)
140                    .await;
141
142                Ok::<_, anyhow::Error>(())
143            }
144            .in_current_span(),
145        );
146
147        Ok(rx)
148    }
149
150    pub fn connections(&self) -> impl Iterator<Item = &LoadedConnection> {
151        self.loads.iter()
152    }
153
154    pub fn random_connection(&self) -> Option<Arc<RwLock<EstablishedConnection>>> {
155        let loads: Vec<_> = self.ongoing_loads().collect();
156        loads
157            .choose(&mut rand::thread_rng())
158            .map(|c| c.connection.clone())
159    }
160
161    pub fn push(&mut self, loaded_connection: LoadedConnection) {
162        self.loads.push(loaded_connection);
163    }
164
165    pub fn update(&mut self) {
166        for load in &mut self.loads {
167            load.update();
168        }
169    }
170
171    /// Connections still transferring: neither completed nor terminated early.
172    ///
173    /// Excluding failed connections is what lets the ramp replace them and
174    /// keeps self probes off dead connections.
175    pub fn ongoing_loads(&self) -> impl Iterator<Item = &LoadedConnection> {
176        self.loads.iter().filter(|load| load.is_ongoing())
177    }
178
179    pub fn count_loads(&self) -> usize {
180        self.ongoing_loads().count()
181    }
182
183    /// Number of load-generating connections that terminated early with an
184    /// error.
185    pub fn count_failed_loads(&self) -> usize {
186        self.loads.iter().filter(|load| load.has_failed()).count()
187    }
188
189    pub fn into_connections(self) -> Vec<LoadedConnection> {
190        self.loads
191    }
192}
193
194/// Drives an upload load-generating connection as an open-ended sequence of
195/// bounded POSTs, all sent on the same established connection.
196///
197/// A single unbounded POST cannot be used against a server that caps how much
198/// request body it will buffer: it is rejected with HTTP 413 once it exceeds
199/// the cap, which kills the load part-way through the test. The RPM score then
200/// reflects a network that is barely loaded, so it comes out flatteringly high
201/// rather than simply failing.
202///
203/// Two properties of such caps make this approach work: they apply per-request
204/// rather than per-connection, and a 413 does not close the HTTP/2 connection.
205/// So an unbounded number of bounded requests can ride one connection and keep
206/// the link continuously loaded without tripping the cap.
207struct UploadReissue {
208    /// Maximum bytes sent in any single request.
209    bound: usize,
210    uri: Uri,
211    headers: HeaderMap<HeaderValue>,
212    scoped_headers: Option<ScopedHeaders>,
213    network: Arc<dyn Network>,
214    time: Arc<dyn Time>,
215    shutdown: CancellationToken,
216}
217
218/// Why the request currently being relayed stopped producing events.
219#[derive(Debug, PartialEq, Eq)]
220enum RequestEnd {
221    /// The body sent every byte it was asked for.
222    Finished,
223    /// The channel closed before the body finished, i.e. the transfer died.
224    Died,
225}
226
227impl UploadReissue {
228    /// Relay `first`'s events, then keep issuing further bounded requests on
229    /// `connection` for as long as the consumer keeps listening.
230    async fn run(
231        self,
232        connection: Arc<RwLock<EstablishedConnection>>,
233        first: UnboundedReceiver<BodyEvent>,
234        events_tx: mpsc::UnboundedSender<BodyEvent>,
235    ) {
236        let mut current = first;
237        let mut relay = CumulativeRelay::default();
238        let mut requests = 1usize;
239
240        loop {
241            let ended = loop {
242                let event = tokio::select! {
243                    // Test teardown. Returning silently is correct: the consumer
244                    // tells teardown apart from a failure via
245                    // `LoadedConnection::stop`, which sets `stopping` before it
246                    // observes the channel closing.
247                    _ = self.shutdown.cancelled() => return,
248                    event = current.recv() => event,
249                };
250
251                let Some(event) = event else {
252                    break RequestEnd::Died;
253                };
254
255                match relay.on_event(event) {
256                    RelayAction::Forward(event) => {
257                        // A closed channel means `stop()` was called. Returning
258                        // drops `current`, closing the in-flight body's event
259                        // channel, which is what truncates it -- the same
260                        // mechanism a single-request load uses.
261                        if events_tx.send(event).is_err() {
262                            return;
263                        }
264                    }
265                    RelayAction::RequestFinished => break RequestEnd::Finished,
266                    RelayAction::Fail(event) => {
267                        let _ = events_tx.send(event);
268                        return;
269                    }
270                }
271            };
272
273            if ended == RequestEnd::Died {
274                let _ = events_tx.send(BodyEvent::Failed {
275                    at: self.time.now(),
276                    reason: format!(
277                        "upload terminated early after {} request(s), {} bytes",
278                        requests,
279                        relay.total()
280                    ),
281                });
282                return;
283            }
284
285            if events_tx.is_closed() {
286                return;
287            }
288
289            // Start the replacement before dealing with the finished request, so
290            // the connection is refilled as early as possible. `Finished` fires
291            // when the body hands its last frame to hyper, which still has that
292            // data buffered -- so the new request's frames queue behind the tail
293            // of the old one and the socket never goes idle.
294            let next = match self.issue(&connection) {
295                Ok(next) => next,
296                Err(error) => {
297                    let _ = events_tx.send(BodyEvent::Failed {
298                        at: self.time.now(),
299                        reason: format!("could not start upload request {requests}: {error:#}"),
300                    });
301                    return;
302                }
303            };
304
305            let next = match next.await {
306                Ok(inflight) => inflight.events,
307                Err(error) => {
308                    let _ = events_tx.send(BodyEvent::Failed {
309                        at: self.time.now(),
310                        reason: format!("upload request {requests} failed to start: {error:#}"),
311                    });
312                    return;
313                }
314            };
315
316            requests += 1;
317            tracing::debug!(
318                requests,
319                total_bytes = relay.total(),
320                "re-issued bounded upload request"
321            );
322
323            // Because `Finished` precedes the response, the status of the
324            // request just completed is still unknown. Keep draining its channel
325            // in the background so a late rejection still retires this load.
326            let finished = std::mem::replace(&mut current, next);
327            tokio::spawn(watch_tail(finished, events_tx.clone()).in_current_span());
328        }
329    }
330
331    fn issue(
332        &self,
333        connection: &Arc<RwLock<EstablishedConnection>>,
334    ) -> anyhow::Result<OneshotResult<InflightBody>> {
335        ThroughputClient::upload(self.bound)
336            .with_connection(Arc::clone(connection))
337            .headers(self.headers.clone())
338            .scoped_headers(self.scoped_headers.clone())
339            .send(
340                self.uri.clone(),
341                Arc::clone(&self.network),
342                Arc::clone(&self.time),
343                self.shutdown.clone(),
344            )
345    }
346}
347
348/// Drain a completed request's event channel, forwarding only a terminal
349/// failure.
350///
351/// [`UploadReissue::run`] moves to the next request as soon as the previous body
352/// is fully handed to hyper, which happens before its response status is known.
353/// A rejection therefore arrives after the driver has stopped reading that
354/// channel; without this it would be dropped, leaving the load looking healthy
355/// while the server refuses every request.
356async fn watch_tail(
357    mut events: UnboundedReceiver<BodyEvent>,
358    events_tx: mpsc::UnboundedSender<BodyEvent>,
359) {
360    while let Some(event) = events.recv().await {
361        if matches!(event, BodyEvent::Failed { .. }) {
362            let _ = events_tx.send(event);
363            return;
364        }
365    }
366}
367
368/// Translates the per-request [`BodyEvent`] streams of a re-issued upload into
369/// one continuous stream for the consumer.
370///
371/// Every request's `CountingBody` counts from zero, but [`CounterSeries`] treats
372/// its samples as a cumulative counter and derives goodput from `end - start`.
373/// Forwarding a per-request total would make that difference *negative* at every
374/// request boundary, silently corrupting goodput and the saturation detection
375/// built on top of it. Totals are therefore rebased onto a running sum here.
376#[derive(Debug, Default)]
377struct CumulativeRelay {
378    /// Bytes accounted for by requests that have already completed.
379    base: usize,
380    /// Most recent total reported by the in-flight request.
381    last: usize,
382}
383
384/// What [`UploadReissue::run`] should do with a translated event.
385#[derive(Debug)]
386enum RelayAction {
387    /// Pass this event on to the consumer.
388    Forward(BodyEvent),
389    /// The current request completed; start another. Deliberately forwards
390    /// nothing: a `Finished` would set `finished_at` and retire a load that is
391    /// in fact still running.
392    RequestFinished,
393    /// Terminal failure. Forward it and stop.
394    Fail(BodyEvent),
395}
396
397impl CumulativeRelay {
398    fn on_event(&mut self, event: BodyEvent) -> RelayAction {
399        match event {
400            BodyEvent::ByteCount { at, total } => {
401                self.last = total;
402                RelayAction::Forward(BodyEvent::ByteCount {
403                    at,
404                    total: self.base + total,
405                })
406            }
407            BodyEvent::Finished { .. } => {
408                self.base += self.last;
409                self.last = 0;
410                RelayAction::RequestFinished
411            }
412            BodyEvent::Failed { at, reason } => RelayAction::Fail(BodyEvent::Failed { at, reason }),
413        }
414    }
415
416    /// Total bytes sent across every request so far.
417    fn total(&self) -> usize {
418        self.base + self.last
419    }
420}
421
422/// The observable state of a load-generating transfer.
423///
424/// Split out from [`LoadedConnection`] so the termination logic can be tested
425/// without constructing a real connection.
426#[derive(Debug, Default)]
427struct LoadState {
428    total_bytes_series: CounterSeries,
429    finished_at: Option<Timestamp>,
430    /// Set when the body's event channel closed *without* a `Finished` event,
431    /// i.e. the transfer died mid-flight.
432    failed: bool,
433    /// Why the transfer failed, when a `Failed` event supplied a reason. A
434    /// bare channel closure gives no reason, so this stays `None`.
435    failure_reason: Option<String>,
436    /// Set by [`LoadedConnection::stop`] so the channel closure it causes is
437    /// not misreported as a failure.
438    stopping: bool,
439}
440
441impl LoadState {
442    fn apply(&mut self, event: BodyEvent) {
443        match event {
444            BodyEvent::ByteCount { at, total } => self.total_bytes_series.add(at, total as f64),
445            BodyEvent::Finished { at } => self.finished_at = Some(at),
446            BodyEvent::Failed { reason, .. } => {
447                self.failed = true;
448                self.failure_reason = Some(reason);
449            }
450        }
451    }
452
453    /// Handle the body's event channel closing.
454    ///
455    /// `CountingBody` owns the only sender, so a closed channel means the body
456    /// was dropped. If that happened before a `Finished` event — and we are not
457    /// deliberately tearing the load down — the transfer terminated early
458    /// (stream reset, connection error, rejected request, ...).
459    ///
460    /// Without this, such a connection keeps `finished_at == None` forever and
461    /// lingers in `ongoing_loads()` as a zombie: it contributes no further
462    /// bytes to goodput yet still occupies a slot in the connection ramp.
463    fn on_disconnected(&mut self) {
464        if self.finished_at.is_none() && !self.stopping {
465            self.failed = true;
466        }
467    }
468
469    /// Whether the transfer is still running (neither completed nor failed).
470    ///
471    /// `finished_at == None` is the normal healthy state for an upload:
472    /// [`UploadReissue`] replaces each bounded request as it completes and
473    /// swallows the per-request `Finished`, so an upload load never reports
474    /// completion. That is exactly why a failure needs its own signal.
475    fn is_ongoing(&self) -> bool {
476        self.finished_at.is_none() && !self.failed
477    }
478
479    /// Drain all currently-available body events, and notice if the channel has
480    /// closed.
481    ///
482    /// `try_recv` yields any buffered events before reporting `Disconnected`,
483    /// so a body that emitted `Finished` and was then dropped is correctly seen
484    /// as completed rather than failed.
485    fn drain(&mut self, events_rx: &mut UnboundedReceiver<BodyEvent>) {
486        loop {
487            match events_rx.try_recv() {
488                Ok(event) => self.apply(event),
489                Err(TryRecvError::Empty) => break,
490                Err(TryRecvError::Disconnected) => {
491                    self.on_disconnected();
492                    break;
493                }
494            }
495        }
496    }
497}
498
499#[derive(Debug)]
500pub struct LoadedConnection {
501    connection: Arc<RwLock<EstablishedConnection>>,
502    events_rx: UnboundedReceiver<BodyEvent>,
503    state: LoadState,
504}
505
506impl LoadedConnection {
507    pub fn update(&mut self) {
508        self.state.drain(&mut self.events_rx);
509    }
510
511    pub fn total_bytes_series(&self) -> &CounterSeries {
512        &self.state.total_bytes_series
513    }
514
515    /// Whether this connection is still transferring.
516    pub fn is_ongoing(&self) -> bool {
517        self.state.is_ongoing()
518    }
519
520    /// Whether this connection terminated early with an error.
521    pub fn has_failed(&self) -> bool {
522        self.state.failed
523    }
524
525    /// Why this connection failed, if a reason was reported.
526    pub fn failure_reason(&self) -> Option<&str> {
527        self.state.failure_reason.as_deref()
528    }
529
530    pub fn stop(&mut self) {
531        self.state.stopping = true;
532        self.events_rx.close();
533        self.update();
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use std::time::Duration;
541    use tokio::sync::mpsc;
542
543    fn channel() -> (
544        mpsc::UnboundedSender<BodyEvent>,
545        mpsc::UnboundedReceiver<BodyEvent>,
546    ) {
547        mpsc::unbounded_channel()
548    }
549
550    /// Feed a `ByteCount` through the relay and return the total it forwarded.
551    fn forward_bytes(relay: &mut CumulativeRelay, at: Timestamp, total: usize) -> usize {
552        match relay.on_event(BodyEvent::ByteCount { at, total }) {
553            RelayAction::Forward(BodyEvent::ByteCount { total, .. }) => total,
554            other => panic!("a ByteCount must be forwarded, got {other:?}"),
555        }
556    }
557
558    #[test]
559    fn totals_accumulate_across_request_boundaries() {
560        let at = Timestamp::now();
561        let mut relay = CumulativeRelay::default();
562
563        assert_eq!(forward_bytes(&mut relay, at, 40), 40);
564        assert_eq!(forward_bytes(&mut relay, at, 100), 100);
565        relay.on_event(BodyEvent::Finished { at });
566
567        // The next request counts from zero again; the consumer must not see
568        // that reset.
569        assert_eq!(forward_bytes(&mut relay, at, 0), 100);
570        assert_eq!(forward_bytes(&mut relay, at, 30), 130);
571        relay.on_event(BodyEvent::Finished { at });
572
573        assert_eq!(forward_bytes(&mut relay, at, 5), 135);
574        assert_eq!(relay.total(), 135);
575    }
576
577    #[test]
578    fn request_finished_is_never_forwarded() {
579        // A forwarded `Finished` would set `finished_at`, so `is_ongoing()` would
580        // go false and the ramp would retire a connection that is still running.
581        let at = Timestamp::now();
582        let mut relay = CumulativeRelay::default();
583
584        assert!(matches!(
585            relay.on_event(BodyEvent::Finished { at }),
586            RelayAction::RequestFinished
587        ));
588    }
589
590    #[test]
591    fn failure_is_terminal_and_forwarded() {
592        let at = Timestamp::now();
593        let mut relay = CumulativeRelay::default();
594
595        let action = relay.on_event(BodyEvent::Failed {
596            at,
597            reason: "upload rejected with status 413 Payload Too Large".to_owned(),
598        });
599
600        match action {
601            RelayAction::Fail(BodyEvent::Failed { reason, .. }) => {
602                assert!(reason.contains("413"));
603            }
604            other => panic!("a Failed must be forwarded as terminal, got {other:?}"),
605        }
606    }
607
608    // The regression that matters most. `CounterSeries::interval_sum` is
609    // `end - start`, so if a request boundary ever let a total reset to zero
610    // reach the series, goodput for that window would go *negative* -- which
611    // would silently corrupt the saturation detection that decides when the
612    // test has reached working conditions.
613    #[test]
614    fn relayed_totals_never_produce_negative_goodput() {
615        let start = Timestamp::now();
616        let step = Duration::from_millis(50);
617
618        let mut relay = CumulativeRelay::default();
619        let mut series = CounterSeries::default();
620        let mut at = start;
621
622        // Three consecutive 100-byte requests, each reporting in 25-byte steps.
623        for _ in 0..3 {
624            for total in [0usize, 25, 50, 75, 100] {
625                at = at + step;
626                let forwarded = forward_bytes(&mut relay, at, total);
627                series.add(at, forwarded as f64);
628            }
629            at = at + step;
630            relay.on_event(BodyEvent::Finished { at });
631        }
632
633        assert_eq!(relay.total(), 300, "three 100-byte requests");
634
635        let mut window = start;
636        while window < at {
637            let next = window + step;
638            let bytes = series.interval_sum(window, next);
639            assert!(
640                bytes >= 0.0,
641                "negative goodput ({bytes}) in one window -- a request boundary leaked a reset"
642            );
643            window = next;
644        }
645
646        assert_eq!(
647            series.interval_sum(start, at),
648            300.0,
649            "the whole run must account for every byte exactly once"
650        );
651    }
652
653    #[test]
654    fn open_channel_leaves_transfer_ongoing() {
655        let (tx, mut rx) = channel();
656        tx.send(BodyEvent::ByteCount {
657            at: Timestamp::now(),
658            total: 1024,
659        })
660        .unwrap();
661
662        let mut state = LoadState::default();
663        state.drain(&mut rx);
664
665        assert!(state.is_ongoing());
666        assert!(!state.failed);
667        // Keep the sender alive: an open channel must not look like a failure.
668        drop(tx);
669    }
670
671    #[test]
672    fn disconnect_without_finished_marks_failed() {
673        let (tx, mut rx) = channel();
674        tx.send(BodyEvent::ByteCount {
675            at: Timestamp::now(),
676            total: 10 * 1024 * 1024,
677        })
678        .unwrap();
679        // The body was dropped mid-transfer (e.g. the server rejected the
680        // upload with 413), closing the channel without a `Finished` event.
681        drop(tx);
682
683        let mut state = LoadState::default();
684        state.drain(&mut rx);
685
686        assert!(state.failed, "early termination must be flagged");
687        assert!(!state.is_ongoing(), "a failed load must not stay ongoing");
688    }
689
690    #[test]
691    fn finished_then_disconnect_is_not_a_failure() {
692        let (tx, mut rx) = channel();
693        let at = Timestamp::now();
694        tx.send(BodyEvent::ByteCount { at, total: 512 }).unwrap();
695        tx.send(BodyEvent::Finished { at }).unwrap();
696        // Normal completion: the body is dropped right after finishing. The
697        // buffered events must be drained before `Disconnected` is observed.
698        drop(tx);
699
700        let mut state = LoadState::default();
701        state.drain(&mut rx);
702
703        assert!(!state.failed, "a completed transfer must not be a failure");
704        assert_eq!(state.finished_at, Some(at));
705        assert!(!state.is_ongoing(), "a completed load is no longer ongoing");
706    }
707
708    #[test]
709    fn teardown_disconnect_is_not_a_failure() {
710        // `stop()` closes the receiver itself; that must not be mistaken for
711        // the connection dying, otherwise every run would end "with failures".
712        let (tx, mut rx) = channel();
713        drop(tx);
714
715        let mut state = LoadState::default();
716        state.stopping = true;
717        state.drain(&mut rx);
718
719        assert!(!state.failed, "teardown must not be flagged as a failure");
720    }
721
722    #[test]
723    fn explicit_failed_event_retires_the_load_with_a_reason() {
724        // e.g. an upload rejected with 413: the client reports the failure the
725        // body itself cannot see.
726        let (tx, mut rx) = channel();
727        let at = Timestamp::now();
728        tx.send(BodyEvent::ByteCount { at, total: 1024 }).unwrap();
729        tx.send(BodyEvent::Failed {
730            at,
731            reason: "upload rejected with status 413 Payload Too Large".to_owned(),
732        })
733        .unwrap();
734
735        let mut state = LoadState::default();
736        state.drain(&mut rx);
737
738        assert!(state.failed);
739        assert!(!state.is_ongoing());
740        assert_eq!(
741            state.failure_reason.as_deref(),
742            Some("upload rejected with status 413 Payload Too Large")
743        );
744        // Sender still alive: the failure must be recognised from the event
745        // alone, without relying on the channel closing.
746        drop(tx);
747    }
748
749    #[test]
750    fn bytes_seen_before_failure_are_retained() {
751        // A failed connection still transferred real bytes; goodput accounting
752        // must keep them.
753        let (tx, mut rx) = channel();
754        let at = Timestamp::now();
755        tx.send(BodyEvent::ByteCount { at, total: 4096 }).unwrap();
756        drop(tx);
757
758        let mut state = LoadState::default();
759        state.drain(&mut rx);
760
761        assert!(state.failed);
762        assert_eq!(state.total_bytes_series.sum(), 4096.0);
763    }
764}